mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d11dea042 | |||
| 0268c769bc | |||
| d1e0253a70 | |||
| 10afe53a5d | |||
| 1b70956451 | |||
| 3776b31794 | |||
| 5c7ea33fb0 | |||
| 33f60ce2be | |||
| c0386cea5a | |||
| 7cea53130c | |||
| 0636989d67 | |||
| 219883266c | |||
| 55fc2b2bc8 | |||
| 6e4ef9a099 | |||
| ebf7d1e3a1 | |||
| 998162bc48 | |||
| 4fadc54b4e | |||
| af7b83750c | |||
| 44c53ff774 | |||
| f4ac9a8292 | |||
| 7c8a7606b7 | |||
| 225217330b | |||
| 589c04a530 | |||
| aa538a3a51 | |||
| 817e108ff5 | |||
| 33542d0c54 | |||
| f37d22f13d | |||
| 202ae903ac | |||
| 6ab5cc367c | |||
| 21559045ba | |||
| d7c57a7a48 | |||
| 11b2ef4788 | |||
| 6fefd51cce | |||
| 65af826222 | |||
| 12eb54c653 | |||
| 5aa1427e64 |
@@ -6,19 +6,9 @@ permissions:
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
paths:
|
|
||||||
- "apps/web/**/*.ts"
|
|
||||||
- "apps/web/**/*.tsx"
|
|
||||||
- "apps/web/locales/**/*.json"
|
|
||||||
- "scan-translations.ts"
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
|
||||||
- "apps/web/**/*.ts"
|
|
||||||
- "apps/web/**/*.tsx"
|
|
||||||
- "apps/web/locales/**/*.json"
|
|
||||||
- "scan-translations.ts"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-translations:
|
validate-translations:
|
||||||
@@ -33,30 +23,38 @@ jobs:
|
|||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Check for relevant changes
|
||||||
|
id: changes
|
||||||
|
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
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}'
|
||||||
|
|
||||||
- name: Setup Node.js 22.x
|
- name: Setup Node.js 22.x
|
||||||
|
if: steps.changes.outputs.translations == 'true'
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
|
if: steps.changes.outputs.translations == 'true'
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.changes.outputs.translations == 'true'
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Validate translation keys
|
- name: Validate translation keys
|
||||||
run: |
|
if: steps.changes.outputs.translations == 'true'
|
||||||
echo ""
|
run: pnpm run scan-translations
|
||||||
echo "🔍 Validating translation keys..."
|
|
||||||
echo ""
|
|
||||||
pnpm run scan-translations
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Skip (no translation-related changes)
|
||||||
if: success()
|
if: steps.changes.outputs.translations != 'true'
|
||||||
run: |
|
run: echo "No translation-related files changed — skipping validation."
|
||||||
echo ""
|
|
||||||
echo "✅ Translation validation completed successfully!"
|
|
||||||
echo ""
|
|
||||||
|
|||||||
+1
-40
@@ -1,40 +1 @@
|
|||||||
# Load environment variables from .env files
|
pnpm lint-staged
|
||||||
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
|
|
||||||
+7
-4
@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
|
|||||||
const isChecked =
|
const isChecked =
|
||||||
notificationType === "unsubscribedOrganizationIds"
|
notificationType === "unsubscribedOrganizationIds"
|
||||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||||
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
|
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
|
||||||
|
|
||||||
const handleSwitchChange = async () => {
|
const handleSwitchChange = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
|
updatedNotificationSettings[notificationType] = {
|
||||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
...updatedNotificationSettings[notificationType],
|
||||||
|
[surveyOrProjectOrOrganizationId]:
|
||||||
|
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||||
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
|
|||||||
) {
|
) {
|
||||||
switch (notificationType) {
|
switch (notificationType) {
|
||||||
case "alert":
|
case "alert":
|
||||||
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
|
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
|
||||||
handleSwitchChange();
|
handleSwitchChange();
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
|
|||||||
+29
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||||
|
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
|
||||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
@@ -106,3 +107,31 @@ export const getResponseCountAction = authenticatedActionClient
|
|||||||
|
|
||||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
-4
@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
|
|||||||
surveySummary: TSurveySummary["meta"];
|
surveySummary: TSurveySummary["meta"];
|
||||||
quotasCount: number;
|
quotasCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
tab: "dropOffs" | "quotas" | undefined;
|
tab: "dropOffs" | "quotas" | "impressions" | undefined;
|
||||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
|
||||||
isQuotasAllowed: boolean;
|
isQuotasAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||||
|
|
||||||
const handleTabChange = (val: "dropOffs" | "quotas") => {
|
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
|
||||||
const change = tab === val ? undefined : val;
|
const change = tab === val ? undefined : val;
|
||||||
setTab(change);
|
setTab(change);
|
||||||
};
|
};
|
||||||
@@ -65,12 +65,16 @@ 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`,
|
`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"
|
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||||
)}>
|
)}>
|
||||||
<StatCard
|
<InteractiveCard
|
||||||
|
key="impressions"
|
||||||
|
tab="impressions"
|
||||||
label={t("environments.surveys.summary.impressions")}
|
label={t("environments.surveys.summary.impressions")}
|
||||||
percentage={null}
|
percentage={null}
|
||||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||||
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
onClick={() => handleTabChange("impressions")}
|
||||||
|
isActive={tab === "impressions"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("environments.surveys.summary.starts")}
|
label={t("environments.surveys.summary.starts")}
|
||||||
|
|||||||
+84
-3
@@ -1,21 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import {
|
||||||
|
getDisplaysWithContactAction,
|
||||||
|
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 { 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 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 { 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 { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
|
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
|
||||||
import { SummaryList } from "./SummaryList";
|
import { SummaryList } from "./SummaryList";
|
||||||
import { SummaryMetadata } from "./SummaryMetadata";
|
import { SummaryMetadata } from "./SummaryMetadata";
|
||||||
|
|
||||||
|
const DISPLAYS_PER_PAGE = 15;
|
||||||
|
|
||||||
const defaultSurveySummary: TSurveySummary = {
|
const defaultSurveySummary: TSurveySummary = {
|
||||||
meta: {
|
meta: {
|
||||||
completedPercentage: 0,
|
completedPercentage: 0,
|
||||||
@@ -51,17 +61,76 @@ export const SummaryPage = ({
|
|||||||
initialSurveySummary,
|
initialSurveySummary,
|
||||||
isQuotasAllowed,
|
isQuotasAllowed,
|
||||||
}: SummaryPageProps) => {
|
}: SummaryPageProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||||
initialSurveySummary || defaultSurveySummary
|
initialSurveySummary || defaultSurveySummary
|
||||||
);
|
);
|
||||||
|
|
||||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
|
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||||
|
|
||||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
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
|
// Only fetch data when filters change or when there's no initial data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we have initial data and no filters are applied, don't fetch
|
// If we have initial data and no filters are applied, don't fetch
|
||||||
@@ -121,6 +190,18 @@ export const SummaryPage = ({
|
|||||||
setTab={setTab}
|
setTab={setTab}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
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} />}
|
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||||
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
|
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
|
|||||||
+2
-2
@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
|||||||
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
||||||
|
|
||||||
interface InteractiveCardProps {
|
interface InteractiveCardProps {
|
||||||
tab: "dropOffs" | "quotas";
|
tab: "dropOffs" | "quotas" | "impressions";
|
||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number | null;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
tooltipText: string;
|
tooltipText: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|||||||
+39
-2
@@ -1,12 +1,49 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
import {
|
||||||
|
TIntegrationGoogleSheets,
|
||||||
|
ZIntegrationGoogleSheets,
|
||||||
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
|
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
|
||||||
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
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({
|
const ZGetSpreadsheetNameByIdAction = z.object({
|
||||||
googleSheetIntegration: ZIntegrationGoogleSheets,
|
googleSheetIntegration: ZIntegrationGoogleSheets,
|
||||||
environmentId: z.string(),
|
environmentId: z.string(),
|
||||||
|
|||||||
+19
-5
@@ -20,6 +20,10 @@ import {
|
|||||||
isValidGoogleSheetsUrl,
|
isValidGoogleSheetsUrl,
|
||||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -118,6 +122,17 @@ export const AddIntegrationModal = ({
|
|||||||
resetForm();
|
resetForm();
|
||||||
}, [selectedIntegration, surveys]);
|
}, [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 () => {
|
const linkSheet = async () => {
|
||||||
try {
|
try {
|
||||||
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
|
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
|
||||||
@@ -129,6 +144,7 @@ export const AddIntegrationModal = ({
|
|||||||
if (selectedElements.length === 0) {
|
if (selectedElements.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
|
setIsLinkingSheet(true);
|
||||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||||
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
||||||
googleSheetIntegration,
|
googleSheetIntegration,
|
||||||
@@ -137,13 +153,11 @@ export const AddIntegrationModal = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!spreadsheetNameResponse?.data) {
|
if (!spreadsheetNameResponse?.data) {
|
||||||
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
|
showErrorMessageToast(spreadsheetNameResponse);
|
||||||
throw new Error(errorMessage);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spreadsheetName = spreadsheetNameResponse.data;
|
const spreadsheetName = spreadsheetNameResponse.data;
|
||||||
|
|
||||||
setIsLinkingSheet(true);
|
|
||||||
integrationData.spreadsheetId = spreadsheetId;
|
integrationData.spreadsheetId = spreadsheetId;
|
||||||
integrationData.spreadsheetName = spreadsheetName;
|
integrationData.spreadsheetName = spreadsheetName;
|
||||||
integrationData.surveyId = selectedSurvey.id;
|
integrationData.surveyId = selectedSurvey.id;
|
||||||
@@ -280,7 +294,7 @@ export const AddIntegrationModal = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{surveyElements.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
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 { 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 { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
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 { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||||
|
|
||||||
@@ -35,10 +37,23 @@ export const GoogleSheetWrapper = ({
|
|||||||
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
||||||
);
|
);
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
|
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
|
||||||
const [selectedIntegration, setSelectedIntegration] = useState<
|
const [selectedIntegration, setSelectedIntegration] = useState<
|
||||||
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
|
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
|
||||||
>(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 () => {
|
const handleGoogleAuthorization = async () => {
|
||||||
authorize(environment.id, webAppUrl).then((url: string) => {
|
authorize(environment.id, webAppUrl).then((url: string) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
@@ -64,6 +79,8 @@ export const GoogleSheetWrapper = ({
|
|||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
setSelectedIntegration={setSelectedIntegration}
|
setSelectedIntegration={setSelectedIntegration}
|
||||||
|
showReconnectButton={showReconnectButton}
|
||||||
|
handleGoogleAuthorization={handleGoogleAuthorization}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+31
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Trash2Icon } from "lucide-react";
|
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -12,15 +12,19 @@ import { TUserLocale } from "@formbricks/types/user";
|
|||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||||
setIsConnected: (v: boolean) => void;
|
setIsConnected: (v: boolean) => void;
|
||||||
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
|
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
|
||||||
|
showReconnectButton: boolean;
|
||||||
|
handleGoogleAuthorization: () => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +33,8 @@ export const ManageIntegration = ({
|
|||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
setSelectedIntegration,
|
setSelectedIntegration,
|
||||||
|
showReconnectButton,
|
||||||
|
handleGoogleAuthorization,
|
||||||
locale,
|
locale,
|
||||||
}: ManageIntegrationProps) => {
|
}: ManageIntegrationProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -68,7 +74,17 @@ export const ManageIntegration = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||||
<div className="flex w-full justify-end">
|
{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="mr-6 flex items-center">
|
<div className="mr-6 flex items-center">
|
||||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
@@ -77,6 +93,19 @@ export const ManageIntegration = ({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedIntegration(null);
|
setSelectedIntegration(null);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||||
import { truncateText } from "@/lib/utils/strings";
|
import { truncateText } from "@/lib/utils/strings";
|
||||||
|
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
|
||||||
|
|
||||||
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||||
let result: string[] = [];
|
let result: string[] = [];
|
||||||
@@ -256,10 +257,16 @@ const processElementResponse = (
|
|||||||
const selectedChoiceIds = responseValue as string[];
|
const selectedChoiceIds = responseValue as string[];
|
||||||
return element.choices
|
return element.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => choice.imageUrl)
|
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
|
||||||
|
return responseValue
|
||||||
|
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
return processResponseData(responseValue);
|
return processResponseData(responseValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
|
|||||||
|
|
||||||
responses[resp] = (pictureElement as any)?.choices
|
responses[resp] = (pictureElement as any)?.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => choice.imageUrl);
|
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
|
|||||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||||
@@ -95,12 +96,15 @@ export const POST = async (request: Request) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||||
|
|
||||||
const webhookPromises = webhooks.map((webhook) => {
|
const webhookPromises = webhooks.map((webhook) => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
webhookId: webhook.id,
|
webhookId: webhook.id,
|
||||||
event,
|
event,
|
||||||
data: {
|
data: {
|
||||||
...response,
|
...response,
|
||||||
|
data: resolvedResponseData,
|
||||||
survey: {
|
survey: {
|
||||||
title: survey.name,
|
title: survey.name,
|
||||||
type: survey.type,
|
type: survey.type,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import {
|
import {
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
export const GET = async (req: Request) => {
|
export const GET = async (req: Request) => {
|
||||||
@@ -42,33 +43,39 @@ export const GET = async (req: Request) => {
|
|||||||
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
|
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
|
|
||||||
let key;
|
if (!code) {
|
||||||
let userEmail;
|
return Response.redirect(
|
||||||
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||||
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 = {
|
const googleSheetIntegration = {
|
||||||
type: "googleSheets" as "googleSheets",
|
type: integrationType,
|
||||||
environment: environmentId,
|
environment: environmentId,
|
||||||
config: {
|
config: {
|
||||||
key,
|
key,
|
||||||
data: [],
|
data: existingConfig?.data ?? [],
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TJsEnvironmentStateSurvey,
|
TJsEnvironmentStateSurvey,
|
||||||
} from "@formbricks/types/js";
|
} from "@formbricks/types/js";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
|||||||
overlay: environmentData.project.overlay,
|
overlay: environmentData.project.overlay,
|
||||||
placement: environmentData.project.placement,
|
placement: environmentData.project.placement,
|
||||||
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
||||||
styling: environmentData.project.styling,
|
styling: resolveStorageUrlsInObject(environmentData.project.styling),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
organization: {
|
organization: {
|
||||||
id: environmentData.project.organization.id,
|
id: environmentData.project.organization.id,
|
||||||
billing: environmentData.project.organization.billing,
|
billing: environmentData.project.organization.billing,
|
||||||
},
|
},
|
||||||
surveys: transformedSurveys,
|
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -44,13 +44,10 @@ const validateResponse = (
|
|||||||
...responseUpdateInput.data,
|
...responseUpdateInput.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFinished = responseUpdateInput.finished ?? false;
|
|
||||||
|
|
||||||
const validationErrors = validateResponseData(
|
const validationErrors = validateResponseData(
|
||||||
survey.blocks,
|
survey.blocks,
|
||||||
mergedData,
|
mergedData,
|
||||||
responseUpdateInput.language ?? response.language ?? "en",
|
responseUpdateInput.language ?? response.language ?? "en",
|
||||||
isFinished,
|
|
||||||
survey.questions
|
survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
|
|||||||
survey.blocks,
|
survey.blocks,
|
||||||
responseInputData.data,
|
responseInputData.data,
|
||||||
responseInputData.language ?? "en",
|
responseInputData.language ?? "en",
|
||||||
responseInputData.finished,
|
|
||||||
survey.questions
|
survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
|
|
||||||
async function fetchAndAuthorizeResponse(
|
async function fetchAndAuthorizeResponse(
|
||||||
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.response),
|
response: responses.successResponse({
|
||||||
|
...result.response,
|
||||||
|
data: resolveStorageUrlsInObject(result.response.data),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
|
|||||||
result.survey.blocks,
|
result.survey.blocks,
|
||||||
responseUpdate.data,
|
responseUpdate.data,
|
||||||
responseUpdate.language ?? "en",
|
responseUpdate.language ?? "en",
|
||||||
responseUpdate.finished,
|
|
||||||
result.survey.questions
|
result.survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updated),
|
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import {
|
import {
|
||||||
createResponseWithQuotaEvaluation,
|
createResponseWithQuotaEvaluation,
|
||||||
getResponses,
|
getResponses,
|
||||||
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
|
|||||||
allResponses.push(...environmentResponses);
|
allResponses.push(...environmentResponses);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(allResponses),
|
response: responses.successResponse(
|
||||||
|
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
surveyResult.survey.blocks,
|
surveyResult.survey.blocks,
|
||||||
responseInput.data,
|
responseInput.data,
|
||||||
responseInput.language ?? "en",
|
responseInput.language ?? "en",
|
||||||
responseInput.finished,
|
|
||||||
surveyResult.survey.questions
|
surveyResult.survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
|||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
|
|
||||||
const fetchAndAuthorizeSurvey = async (
|
const fetchAndAuthorizeSurvey = async (
|
||||||
surveyId: string,
|
surveyId: string,
|
||||||
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
|
|||||||
|
|
||||||
if (shouldTransformToQuestions) {
|
if (shouldTransformToQuestions) {
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse({
|
response: responses.successResponse(
|
||||||
...result.survey,
|
resolveStorageUrlsInObject({
|
||||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
...result.survey,
|
||||||
blocks: [],
|
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||||
}),
|
blocks: [],
|
||||||
|
})
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.survey),
|
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveyWithQuestions),
|
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updatedSurvey),
|
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
|||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { createSurvey } from "@/lib/survey/service";
|
import { createSurvey } from "@/lib/survey/service";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { getSurveys } from "./lib/surveys";
|
import { getSurveys } from "./lib/surveys";
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveysWithQuestions),
|
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
survey.blocks,
|
survey.blocks,
|
||||||
responseInputData.data,
|
responseInputData.data,
|
||||||
responseInputData.language ?? "en",
|
responseInputData.language ?? "en",
|
||||||
responseInputData.finished,
|
|
||||||
survey.questions
|
survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||||
|
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -312,6 +313,19 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/c")).toBe(false);
|
expect(isPublicDomainRoute("/c")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
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", () => {
|
test("should return true for client API routes", () => {
|
||||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||||
@@ -375,6 +389,8 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).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/v1/client/test")).toBe(false);
|
||||||
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -390,6 +406,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should allow public routes on public domain", () => {
|
test("should allow public routes on public domain", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", 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/v1/client/test", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||||
@@ -426,6 +443,8 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", 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/v1/client/test", false)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -440,6 +459,8 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle paths with query parameters and fragments", () => {
|
test("should handle paths with query parameters and fragments", () => {
|
||||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||||
expect(isRouteAllowedForDomain("/s/survey123#section", 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", true)).toBe(false);
|
||||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -450,6 +471,7 @@ describe("endpoint-validator", () => {
|
|||||||
describe("URL parsing edge cases", () => {
|
describe("URL parsing edge cases", () => {
|
||||||
test("should handle paths with query parameters", () => {
|
test("should handle paths with query parameters", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
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("/api/v1/client/test?query=data")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||||
@@ -458,12 +480,14 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle paths with fragments", () => {
|
test("should handle paths with fragments", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt-token#top")).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(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle trailing slashes", () => {
|
test("should handle trailing slashes", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -478,6 +502,9 @@ describe("endpoint-validator", () => {
|
|||||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).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", () => {
|
test("should handle nested client API routes", () => {
|
||||||
@@ -529,6 +556,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle special characters in survey IDs", () => {
|
test("should handle special characters in survey IDs", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,6 +564,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should properly validate malicious or injection-like URLs", () => {
|
test("should properly validate malicious or injection-like URLs", () => {
|
||||||
// SQL injection-like attempts
|
// SQL injection-like attempts
|
||||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
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({
|
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||||
@@ -543,10 +572,12 @@ describe("endpoint-validator", () => {
|
|||||||
|
|
||||||
// Path traversal attempts
|
// Path traversal attempts
|
||||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||||
|
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||||
|
|
||||||
// XSS-like attempts
|
// XSS-like attempts
|
||||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
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({
|
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||||
isClientSideApi: true,
|
isClientSideApi: true,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
@@ -556,6 +587,7 @@ describe("endpoint-validator", () => {
|
|||||||
test("should handle URL encoding", () => {
|
test("should handle URL encoding", () => {
|
||||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||||
|
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
|
||||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||||
isManagementApi: true,
|
isManagementApi: true,
|
||||||
@@ -591,6 +623,7 @@ describe("endpoint-validator", () => {
|
|||||||
// These should not match due to case sensitivity
|
// These should not match due to case sensitivity
|
||||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||||
|
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
|
||||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||||
isClientSideApi: false,
|
isClientSideApi: false,
|
||||||
isRateLimited: true,
|
isRateLimited: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
|
|||||||
SURVEY_ROUTES: [
|
SURVEY_ROUTES: [
|
||||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||||
|
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
|
||||||
],
|
],
|
||||||
|
|
||||||
// API routes accessible from public domain
|
// API routes accessible from public domain
|
||||||
|
|||||||
+19
-3
@@ -191,6 +191,7 @@ checksums:
|
|||||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||||
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
||||||
|
common/error_loading_data: aaeffbfe4a2c2145442a57de524494be
|
||||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||||
@@ -198,6 +199,7 @@ checksums:
|
|||||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||||
|
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||||
@@ -210,6 +212,7 @@ checksums:
|
|||||||
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||||
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
||||||
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
||||||
|
common/id: c8886d38aeea2ed5f785aba4fc96784b
|
||||||
common/image: 048ba7a239de0fbd883ade8558415830
|
common/image: 048ba7a239de0fbd883ade8558415830
|
||||||
common/images: 9305827c28694866f49db42b4c51831f
|
common/images: 9305827c28694866f49db42b4c51831f
|
||||||
common/import: 348b8ab981de5b7f1fca6d7302263bbd
|
common/import: 348b8ab981de5b7f1fca6d7302263bbd
|
||||||
@@ -227,6 +230,7 @@ checksums:
|
|||||||
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||||
common/label: a5c71bf158481233f8215dbd38cc196b
|
common/label: a5c71bf158481233f8215dbd38cc196b
|
||||||
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||||
|
common/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||||
common/learn_more: e598091d132f890c37a6d4ed94f6d794
|
common/learn_more: e598091d132f890c37a6d4ed94f6d794
|
||||||
common/license_expired: 7af13535e320e4197989472c01387d2c
|
common/license_expired: 7af13535e320e4197989472c01387d2c
|
||||||
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
|
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
|
||||||
@@ -401,6 +405,7 @@ checksums:
|
|||||||
common/top_right: 241f95c923846911aaf13af6109333e5
|
common/top_right: 241f95c923846911aaf13af6109333e5
|
||||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||||
|
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||||
common/update: 079fc039262fd31b10532929685c2d1b
|
common/update: 079fc039262fd31b10532929685c2d1b
|
||||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||||
@@ -609,7 +614,6 @@ checksums:
|
|||||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||||
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
|
|
||||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||||
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
||||||
@@ -620,6 +624,7 @@ checksums:
|
|||||||
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
||||||
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
||||||
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
||||||
|
environments/contacts/displays: fcc4527002bd045021882be463b8ac72
|
||||||
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
|
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
|
||||||
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
||||||
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
||||||
@@ -631,6 +636,7 @@ checksums:
|
|||||||
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||||
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||||
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||||
|
environments/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
|
||||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||||
@@ -645,6 +651,8 @@ checksums:
|
|||||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||||
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
||||||
|
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
|
||||||
|
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
|
||||||
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
||||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||||
@@ -711,7 +719,12 @@ checksums:
|
|||||||
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
||||||
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
||||||
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
|
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/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
|
||||||
|
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
|
||||||
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
||||||
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
||||||
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
||||||
@@ -1846,6 +1859,7 @@ checksums:
|
|||||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||||
|
environments/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165
|
||||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||||
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||||
@@ -1886,6 +1900,7 @@ checksums:
|
|||||||
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
|
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
|
||||||
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
|
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
|
||||||
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
||||||
|
environments/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||||
@@ -2036,12 +2051,12 @@ checksums:
|
|||||||
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
|
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
|
||||||
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
|
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
|
||||||
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
|
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
|
||||||
environments/workspace/look/advanced_styling_field_height: f4da6d7ecd26e3fa75cfea03abb60c00
|
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
|
||||||
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
|
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
|
||||||
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
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_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
||||||
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
||||||
environments/workspace/look/advanced_styling_field_input_height_description: b704fc67e805223992c811d6f86a9c00
|
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_x_description: 10e14296468321c13fda77fd1ba58dfd
|
||||||
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
||||||
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
||||||
@@ -2104,6 +2119,7 @@ checksums:
|
|||||||
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
|
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
|
||||||
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
|
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
|
||||||
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
|
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
|
||||||
|
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
|
||||||
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
|
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
|
||||||
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
|
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
|
||||||
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
|
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
|
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
|
|
||||||
@@ -23,13 +24,12 @@ export const getDisplayCountBySurveyId = reactCache(
|
|||||||
const displayCount = await prisma.display.count({
|
const displayCount = await prisma.display.count({
|
||||||
where: {
|
where: {
|
||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
...(filters &&
|
...(filters?.createdAt && {
|
||||||
filters.createdAt && {
|
createdAt: {
|
||||||
createdAt: {
|
gte: filters.createdAt.min,
|
||||||
gte: filters.createdAt.min,
|
lte: filters.createdAt.max,
|
||||||
lte: filters.createdAt.max,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return displayCount;
|
return displayCount;
|
||||||
@@ -42,6 +42,97 @@ 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> => {
|
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||||
validateInputs([displayId, ZId]);
|
validateInputs([displayId, ZId]);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 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,7 +2,12 @@ import "server-only";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZString } from "@formbricks/types/common";
|
import { ZString } from "@formbricks/types/common";
|
||||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
import {
|
||||||
|
AuthenticationError,
|
||||||
|
DatabaseError,
|
||||||
|
OperationNotAllowedError,
|
||||||
|
UnknownError,
|
||||||
|
} from "@formbricks/types/errors";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
ZIntegrationGoogleSheets,
|
ZIntegrationGoogleSheets,
|
||||||
@@ -11,8 +16,12 @@ import {
|
|||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
GOOGLE_SHEETS_REDIRECT_URL,
|
GOOGLE_SHEETS_REDIRECT_URL,
|
||||||
|
GOOGLE_SHEET_MESSAGE_LIMIT,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
|
import {
|
||||||
|
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
|
||||||
|
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
|
||||||
|
} from "@/lib/googleSheet/constants";
|
||||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||||
import { truncateText } from "../utils/strings";
|
import { truncateText } from "../utils/strings";
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
@@ -81,6 +90,17 @@ 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 (
|
export const getSpreadsheetNameById = async (
|
||||||
googleSheetIntegrationData: TIntegrationGoogleSheets,
|
googleSheetIntegrationData: TIntegrationGoogleSheets,
|
||||||
spreadsheetId: string
|
spreadsheetId: string
|
||||||
@@ -94,7 +114,17 @@ export const getSpreadsheetNameById = async (
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
|
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
|
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}`));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const spreadsheetTitle = response.data.properties.title;
|
const spreadsheetTitle = response.data.properties.title;
|
||||||
@@ -109,26 +139,70 @@ 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 authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
|
||||||
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
||||||
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
||||||
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
||||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||||
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
|
const key = googleSheetIntegrationData.config.key;
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
oAuth2Client.setCredentials(credentials);
|
const hasStoredCredentials =
|
||||||
|
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
|
||||||
|
|
||||||
return oAuth2Client;
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|||||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
||||||
import { deleteFile } from "@/modules/storage/service";
|
import { deleteFile } from "@/modules/storage/service";
|
||||||
|
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
import { ITEMS_PER_PAGE } from "../constants";
|
import { ITEMS_PER_PAGE } from "../constants";
|
||||||
@@ -408,9 +409,10 @@ export const getResponseDownloadFile = async (
|
|||||||
if (survey.isVerifyEmailEnabled) {
|
if (survey.isVerifyEmailEnabled) {
|
||||||
headers.push("Verified Email");
|
headers.push("Verified Email");
|
||||||
}
|
}
|
||||||
|
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
|
||||||
const jsonData = getResponsesJson(
|
const jsonData = getResponsesJson(
|
||||||
survey,
|
survey,
|
||||||
responses,
|
resolvedResponses,
|
||||||
elements,
|
elements,
|
||||||
userAttributes,
|
userAttributes,
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
|||||||
// Inputs
|
// Inputs
|
||||||
inputTextColor: { light: _colors["inputTextColor.light"] },
|
inputTextColor: { light: _colors["inputTextColor.light"] },
|
||||||
inputBorderRadius: 8,
|
inputBorderRadius: 8,
|
||||||
inputHeight: 40,
|
inputHeight: 20,
|
||||||
inputFontSize: 14,
|
inputFontSize: 14,
|
||||||
inputPaddingX: 16,
|
inputPaddingX: 8,
|
||||||
inputPaddingY: 16,
|
inputPaddingY: 8,
|
||||||
inputPlaceholderOpacity: 0.5,
|
inputPlaceholderOpacity: 0.5,
|
||||||
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||||
|
|
||||||
@@ -149,6 +149,42 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
|||||||
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
|
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");
|
||||||
|
|
||||||
|
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 } }),
|
||||||
|
...(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.
|
* Builds a complete TProjectStyling object from a single brand color.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ export function validateInputs<T extends ValidationPair<any>[]>(
|
|||||||
for (const [value, schema] of pairs) {
|
for (const [value, schema] of pairs) {
|
||||||
const inputValidation = schema.safeParse(value);
|
const inputValidation = schema.safeParse(value);
|
||||||
if (!inputValidation.success) {
|
if (!inputValidation.success) {
|
||||||
|
const zodDetails = inputValidation.error.issues
|
||||||
|
.map((issue) => {
|
||||||
|
const path = issue?.path?.join(".") ?? "";
|
||||||
|
return `${path}${issue.message}`;
|
||||||
|
})
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
inputValidation.error,
|
inputValidation.error,
|
||||||
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
|
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
|
||||||
);
|
);
|
||||||
throw new ValidationError("Validation failed");
|
throw new ValidationError(`Validation failed: ${zodDetails}`);
|
||||||
}
|
}
|
||||||
parsedData.push(inputValidation.data);
|
parsedData.push(inputValidation.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
"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_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_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||||
"expand_rows": "Zeilen erweitern",
|
"expand_rows": "Zeilen erweitern",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||||
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
|
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
|
||||||
"finish": "Fertigstellen",
|
"finish": "Fertigstellen",
|
||||||
|
"first_name": "Vorname",
|
||||||
"follow_these": "Folge diesen",
|
"follow_these": "Folge diesen",
|
||||||
"formbricks_version": "Formbricks Version",
|
"formbricks_version": "Formbricks Version",
|
||||||
"full_name": "Name",
|
"full_name": "Name",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Verstecktes Feld",
|
"hidden_field": "Verstecktes Feld",
|
||||||
"hidden_fields": "Versteckte Felder",
|
"hidden_fields": "Versteckte Felder",
|
||||||
"hide_column": "Spalte ausblenden",
|
"hide_column": "Spalte ausblenden",
|
||||||
|
"id": "ID",
|
||||||
"image": "Bild",
|
"image": "Bild",
|
||||||
"images": "Bilder",
|
"images": "Bilder",
|
||||||
"import": "Importieren",
|
"import": "Importieren",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Schlüssel",
|
"key": "Schlüssel",
|
||||||
"label": "Bezeichnung",
|
"label": "Bezeichnung",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
|
"last_name": "Nachname",
|
||||||
"learn_more": "Mehr erfahren",
|
"learn_more": "Mehr erfahren",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Helle Überlagerung",
|
"light_overlay": "Helle Überlagerung",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Oben rechts",
|
"top_right": "Oben rechts",
|
||||||
"try_again": "Versuch's nochmal",
|
"try_again": "Versuch's nochmal",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
|
"unknown_survey": "Unbekannte Umfrage",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
|
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
|
||||||
"update": "Aktualisierung",
|
"update": "Aktualisierung",
|
||||||
"updated": "Aktualisiert",
|
"updated": "Aktualisiert",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||||
"create_attribute": "Attribut erstellen",
|
"create_attribute": "Attribut erstellen",
|
||||||
"create_key": "Schlüssel erstellen",
|
|
||||||
"create_new_attribute": "Neues Attribut erstellen",
|
"create_new_attribute": "Neues Attribut erstellen",
|
||||||
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
||||||
"custom_attributes": "Benutzerdefinierte Attribute",
|
"custom_attributes": "Benutzerdefinierte Attribute",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"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_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": "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.}}",
|
"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": "Attribut bearbeiten",
|
||||||
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
|
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
|
||||||
"edit_attribute_values": "Attribute bearbeiten",
|
"edit_attribute_values": "Attribute bearbeiten",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"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_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_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
|
||||||
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
|
"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_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_published_surveys": "Keine veröffentlichten Umfragen",
|
||||||
"no_responses_found": "Keine Antworten gefunden",
|
"no_responses_found": "Keine Antworten gefunden",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Wähle eine Umfrage aus",
|
"select_a_survey": "Wähle eine Umfrage aus",
|
||||||
"select_attribute": "Attribut auswählen",
|
"select_attribute": "Attribut auswählen",
|
||||||
"select_attribute_key": "Attributschlüssel auswählen",
|
"select_attribute_key": "Attributschlüssel auswählen",
|
||||||
|
"survey_viewed": "Umfrage angesehen",
|
||||||
|
"survey_viewed_at": "Angesehen am",
|
||||||
"system_attributes": "Systemattribute",
|
"system_attributes": "Systemattribute",
|
||||||
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
|
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
|
||||||
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
|
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Tabelle verlinken",
|
"link_google_sheet": "Tabelle verlinken",
|
||||||
"link_new_sheet": "Neues Blatt verknüpfen",
|
"link_new_sheet": "Neues Blatt verknüpfen",
|
||||||
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||||
"spreadsheet_url": "Tabellen-URL"
|
"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."
|
||||||
},
|
},
|
||||||
"include_created_at": "Erstellungsdatum einbeziehen",
|
"include_created_at": "Erstellungsdatum einbeziehen",
|
||||||
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
|
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
|
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
|
||||||
"generating_qr_code": "QR-Code wird generiert",
|
"generating_qr_code": "QR-Code wird generiert",
|
||||||
"impressions": "Eindrücke",
|
"impressions": "Eindrücke",
|
||||||
|
"impressions_identified_only": "Zeigt nur Impressionen von identifizierten Kontakten",
|
||||||
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
|
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "Die Umfrage wird den Nutzern Ihrer Website angezeigt, die den unten aufgeführten Kriterien entsprechen",
|
"connection_description": "Die Umfrage wird den Nutzern Ihrer Website angezeigt, die den unten aufgeführten Kriterien entsprechen",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Letztes Quartal",
|
"last_quarter": "Letztes Quartal",
|
||||||
"last_year": "Letztes Jahr",
|
"last_year": "Letztes Jahr",
|
||||||
"limit": "Limit",
|
"limit": "Limit",
|
||||||
|
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
|
||||||
"no_responses_found": "Keine Antworten gefunden",
|
"no_responses_found": "Keine Antworten gefunden",
|
||||||
"other_values_found": "Andere Werte gefunden",
|
"other_values_found": "Andere Werte gefunden",
|
||||||
"overall": "Insgesamt",
|
"overall": "Insgesamt",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
|
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
|
||||||
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
|
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
|
||||||
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
|
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
|
||||||
"advanced_styling_field_height": "Höhe",
|
"advanced_styling_field_height": "Mindesthöhe",
|
||||||
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
|
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
|
||||||
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
|
"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_border_radius_description": "Rundet die Eingabeecken ab.",
|
||||||
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
|
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
|
||||||
"advanced_styling_field_input_height_description": "Steuert die Höhe des Eingabefelds.",
|
"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_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_padding_y_description": "Fügt oben und unten Abstand hinzu.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
|
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
|
||||||
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
|
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
|
||||||
"suggest_colors": "Farben vorschlagen",
|
"suggest_colors": "Farben vorschlagen",
|
||||||
|
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
|
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
|
"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_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_description": "Maximum number of requests reached. Please try again later.",
|
||||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||||
"expand_rows": "Expand rows",
|
"expand_rows": "Expand rows",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Failed to load organizations",
|
"failed_to_load_organizations": "Failed to load organizations",
|
||||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||||
"finish": "Finish",
|
"finish": "Finish",
|
||||||
|
"first_name": "First Name",
|
||||||
"follow_these": "Follow these",
|
"follow_these": "Follow these",
|
||||||
"formbricks_version": "Formbricks Version",
|
"formbricks_version": "Formbricks Version",
|
||||||
"full_name": "Full name",
|
"full_name": "Full name",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Hidden field",
|
"hidden_field": "Hidden field",
|
||||||
"hidden_fields": "Hidden fields",
|
"hidden_fields": "Hidden fields",
|
||||||
"hide_column": "Hide column",
|
"hide_column": "Hide column",
|
||||||
|
"id": "ID",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Key",
|
"key": "Key",
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"last_name": "Last Name",
|
||||||
"learn_more": "Learn more",
|
"learn_more": "Learn more",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Light overlay",
|
"light_overlay": "Light overlay",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Top Right",
|
"top_right": "Top Right",
|
||||||
"try_again": "Try again",
|
"try_again": "Try again",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
"unknown_survey": "Unknown survey",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updated": "Updated",
|
"updated": "Updated",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Refresh contacts",
|
"contacts_table_refresh": "Refresh contacts",
|
||||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||||
"create_attribute": "Create attribute",
|
"create_attribute": "Create attribute",
|
||||||
"create_key": "Create Key",
|
|
||||||
"create_new_attribute": "Create new attribute",
|
"create_new_attribute": "Create new attribute",
|
||||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||||
"custom_attributes": "Custom Attributes",
|
"custom_attributes": "Custom Attributes",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"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_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": "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.}}",
|
"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": "Edit attribute",
|
||||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||||
"edit_attribute_values": "Edit attributes",
|
"edit_attribute_values": "Edit attributes",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"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_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_date_format": "Invalid date format. Please use a valid date.",
|
||||||
"invalid_number_format": "Invalid number format. Please enter a valid number.",
|
"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_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
|
||||||
"no_published_surveys": "No published surveys",
|
"no_published_surveys": "No published surveys",
|
||||||
"no_responses_found": "No responses found",
|
"no_responses_found": "No responses found",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Select a survey",
|
"select_a_survey": "Select a survey",
|
||||||
"select_attribute": "Select Attribute",
|
"select_attribute": "Select Attribute",
|
||||||
"select_attribute_key": "Select attribute key",
|
"select_attribute_key": "Select attribute key",
|
||||||
|
"survey_viewed": "Survey viewed",
|
||||||
|
"survey_viewed_at": "Viewed At",
|
||||||
"system_attributes": "System Attributes",
|
"system_attributes": "System Attributes",
|
||||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Link Google Sheet",
|
"link_google_sheet": "Link Google Sheet",
|
||||||
"link_new_sheet": "Link new Sheet",
|
"link_new_sheet": "Link new Sheet",
|
||||||
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
|
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
|
||||||
"spreadsheet_url": "Spreadsheet URL"
|
"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."
|
||||||
},
|
},
|
||||||
"include_created_at": "Include Created At",
|
"include_created_at": "Include Created At",
|
||||||
"include_hidden_fields": "Include Hidden Fields",
|
"include_hidden_fields": "Include Hidden Fields",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Filtered responses (Excel)",
|
"filtered_responses_excel": "Filtered responses (Excel)",
|
||||||
"generating_qr_code": "Generating QR code",
|
"generating_qr_code": "Generating QR code",
|
||||||
"impressions": "Impressions",
|
"impressions": "Impressions",
|
||||||
|
"impressions_identified_only": "Only showing impressions from identified contacts",
|
||||||
"impressions_tooltip": "Number of times the survey has been viewed.",
|
"impressions_tooltip": "Number of times the survey has been viewed.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "The survey will be shown to users of your website, that match the criteria listed below",
|
"connection_description": "The survey will be shown to users of your website, that match the criteria listed below",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Last quarter",
|
"last_quarter": "Last quarter",
|
||||||
"last_year": "Last year",
|
"last_year": "Last year",
|
||||||
"limit": "Limit",
|
"limit": "Limit",
|
||||||
|
"no_identified_impressions": "No impressions from identified contacts",
|
||||||
"no_responses_found": "No responses found",
|
"no_responses_found": "No responses found",
|
||||||
"other_values_found": "Other values found",
|
"other_values_found": "Other values found",
|
||||||
"overall": "Overall",
|
"overall": "Overall",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Scales the headline text.",
|
"advanced_styling_field_headline_size_description": "Scales the headline text.",
|
||||||
"advanced_styling_field_headline_weight": "Headline Font Weight",
|
"advanced_styling_field_headline_weight": "Headline Font Weight",
|
||||||
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
|
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
|
||||||
"advanced_styling_field_height": "Height",
|
"advanced_styling_field_height": "Minimum Height",
|
||||||
"advanced_styling_field_indicator_bg": "Indicator Background",
|
"advanced_styling_field_indicator_bg": "Indicator Background",
|
||||||
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
|
"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_border_radius_description": "Rounds the input corners.",
|
||||||
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
|
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
|
||||||
"advanced_styling_field_input_height_description": "Controls the input field height.",
|
"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_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_padding_y_description": "Adds space on the top and bottom.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Show “Powered by Formbricks” Signature",
|
"show_powered_by_formbricks": "Show “Powered by Formbricks” Signature",
|
||||||
"styling_updated_successfully": "Styling updated successfully",
|
"styling_updated_successfully": "Styling updated successfully",
|
||||||
"suggest_colors": "Suggest colors",
|
"suggest_colors": "Suggest colors",
|
||||||
|
"suggested_colors_applied_please_save": "Suggested colors generated successfully. Press \"Save\" to persist the changes.",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
|
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
|
"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_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_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",
|
"error_rate_limit_title": "Límite de frecuencia excedido",
|
||||||
"expand_rows": "Expandir filas",
|
"expand_rows": "Expandir filas",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||||
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
||||||
"finish": "Finalizar",
|
"finish": "Finalizar",
|
||||||
|
"first_name": "Nombre",
|
||||||
"follow_these": "Sigue estos",
|
"follow_these": "Sigue estos",
|
||||||
"formbricks_version": "Versión de Formbricks",
|
"formbricks_version": "Versión de Formbricks",
|
||||||
"full_name": "Nombre completo",
|
"full_name": "Nombre completo",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Campo oculto",
|
"hidden_field": "Campo oculto",
|
||||||
"hidden_fields": "Campos ocultos",
|
"hidden_fields": "Campos ocultos",
|
||||||
"hide_column": "Ocultar columna",
|
"hide_column": "Ocultar columna",
|
||||||
|
"id": "ID",
|
||||||
"image": "Imagen",
|
"image": "Imagen",
|
||||||
"images": "Imágenes",
|
"images": "Imágenes",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Clave",
|
"key": "Clave",
|
||||||
"label": "Etiqueta",
|
"label": "Etiqueta",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
|
"last_name": "Apellido",
|
||||||
"learn_more": "Saber más",
|
"learn_more": "Saber más",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Superposición clara",
|
"light_overlay": "Superposición clara",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Superior derecha",
|
"top_right": "Superior derecha",
|
||||||
"try_again": "Intentar de nuevo",
|
"try_again": "Intentar de nuevo",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
|
"unknown_survey": "Encuesta desconocida",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
|
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
|
||||||
"update": "Actualizar",
|
"update": "Actualizar",
|
||||||
"updated": "Actualizado",
|
"updated": "Actualizado",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Actualizar contactos",
|
"contacts_table_refresh": "Actualizar contactos",
|
||||||
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
||||||
"create_attribute": "Crear atributo",
|
"create_attribute": "Crear atributo",
|
||||||
"create_key": "Crear clave",
|
|
||||||
"create_new_attribute": "Crear atributo nuevo",
|
"create_new_attribute": "Crear atributo nuevo",
|
||||||
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
|
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
|
||||||
"custom_attributes": "Atributos personalizados",
|
"custom_attributes": "Atributos personalizados",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"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_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": "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.}}",
|
"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": "Editar atributo",
|
||||||
"edit_attribute_description": "Actualiza la etiqueta y la descripción de este atributo.",
|
"edit_attribute_description": "Actualiza la etiqueta y la descripción de este atributo.",
|
||||||
"edit_attribute_values": "Editar atributos",
|
"edit_attribute_values": "Editar atributos",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"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_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_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.",
|
"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_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_published_surveys": "No hay encuestas publicadas",
|
||||||
"no_responses_found": "No se encontraron respuestas",
|
"no_responses_found": "No se encontraron respuestas",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Selecciona una encuesta",
|
"select_a_survey": "Selecciona una encuesta",
|
||||||
"select_attribute": "Seleccionar atributo",
|
"select_attribute": "Seleccionar atributo",
|
||||||
"select_attribute_key": "Seleccionar clave de atributo",
|
"select_attribute_key": "Seleccionar clave de atributo",
|
||||||
|
"survey_viewed": "Encuesta vista",
|
||||||
|
"survey_viewed_at": "Vista el",
|
||||||
"system_attributes": "Atributos del sistema",
|
"system_attributes": "Atributos del sistema",
|
||||||
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
|
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
|
||||||
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
|
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Vincular Google Sheet",
|
"link_google_sheet": "Vincular Google Sheet",
|
||||||
"link_new_sheet": "Vincular nueva hoja",
|
"link_new_sheet": "Vincular nueva hoja",
|
||||||
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
|
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
|
||||||
"spreadsheet_url": "URL de la hoja de cálculo"
|
"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."
|
||||||
},
|
},
|
||||||
"include_created_at": "Incluir fecha de creación",
|
"include_created_at": "Incluir fecha de creación",
|
||||||
"include_hidden_fields": "Incluir campos ocultos",
|
"include_hidden_fields": "Incluir campos ocultos",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Respuestas filtradas (Excel)",
|
"filtered_responses_excel": "Respuestas filtradas (Excel)",
|
||||||
"generating_qr_code": "Generando código QR",
|
"generating_qr_code": "Generando código QR",
|
||||||
"impressions": "Impresiones",
|
"impressions": "Impresiones",
|
||||||
|
"impressions_identified_only": "Solo se muestran impresiones de contactos identificados",
|
||||||
"impressions_tooltip": "Número de veces que se ha visto la encuesta.",
|
"impressions_tooltip": "Número de veces que se ha visto la encuesta.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "La encuesta se mostrará a los usuarios de tu sitio web que cumplan con los criterios enumerados a continuación",
|
"connection_description": "La encuesta se mostrará a los usuarios de tu sitio web que cumplan con los criterios enumerados a continuación",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Último trimestre",
|
"last_quarter": "Último trimestre",
|
||||||
"last_year": "Último año",
|
"last_year": "Último año",
|
||||||
"limit": "Límite",
|
"limit": "Límite",
|
||||||
|
"no_identified_impressions": "No hay impresiones de contactos identificados",
|
||||||
"no_responses_found": "No se han encontrado respuestas",
|
"no_responses_found": "No se han encontrado respuestas",
|
||||||
"other_values_found": "Otros valores encontrados",
|
"other_values_found": "Otros valores encontrados",
|
||||||
"overall": "General",
|
"overall": "General",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
|
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
|
||||||
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
|
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
|
||||||
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
|
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
|
||||||
"advanced_styling_field_height": "Altura",
|
"advanced_styling_field_height": "Altura mínima",
|
||||||
"advanced_styling_field_indicator_bg": "Fondo del indicador",
|
"advanced_styling_field_indicator_bg": "Fondo del indicador",
|
||||||
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
|
"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_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_font_size_description": "Escala el texto escrito en los campos.",
|
||||||
"advanced_styling_field_input_height_description": "Controla la altura del campo de 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_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_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.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Mostrar firma 'Powered by Formbricks'",
|
"show_powered_by_formbricks": "Mostrar firma 'Powered by Formbricks'",
|
||||||
"styling_updated_successfully": "Estilo actualizado correctamente",
|
"styling_updated_successfully": "Estilo actualizado correctamente",
|
||||||
"suggest_colors": "Sugerir colores",
|
"suggest_colors": "Sugerir colores",
|
||||||
|
"suggested_colors_applied_please_save": "Colores sugeridos generados correctamente. Pulsa \"Guardar\" para conservar los cambios.",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
|
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Erreur",
|
"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_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_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_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
|
||||||
"error_rate_limit_title": "Limite de Taux Dépassée",
|
"error_rate_limit_title": "Limite de Taux Dépassée",
|
||||||
"expand_rows": "Développer les lignes",
|
"expand_rows": "Développer les lignes",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||||
"failed_to_load_workspaces": "Échec du chargement des projets",
|
"failed_to_load_workspaces": "Échec du chargement des projets",
|
||||||
"finish": "Terminer",
|
"finish": "Terminer",
|
||||||
|
"first_name": "Prénom",
|
||||||
"follow_these": "Suivez ceci",
|
"follow_these": "Suivez ceci",
|
||||||
"formbricks_version": "Version de Formbricks",
|
"formbricks_version": "Version de Formbricks",
|
||||||
"full_name": "Nom complet",
|
"full_name": "Nom complet",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Champ caché",
|
"hidden_field": "Champ caché",
|
||||||
"hidden_fields": "Champs cachés",
|
"hidden_fields": "Champs cachés",
|
||||||
"hide_column": "Cacher la colonne",
|
"hide_column": "Cacher la colonne",
|
||||||
|
"id": "ID",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"import": "Importer",
|
"import": "Importer",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Clé",
|
"key": "Clé",
|
||||||
"label": "Étiquette",
|
"label": "Étiquette",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
|
"last_name": "Nom de famille",
|
||||||
"learn_more": "En savoir plus",
|
"learn_more": "En savoir plus",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Claire",
|
"light_overlay": "Claire",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "En haut à droite",
|
"top_right": "En haut à droite",
|
||||||
"try_again": "Réessayer",
|
"try_again": "Réessayer",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
"unknown_survey": "Enquête inconnue",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
|
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
|
||||||
"update": "Mise à jour",
|
"update": "Mise à jour",
|
||||||
"updated": "Mise à jour",
|
"updated": "Mise à jour",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Actualiser les contacts",
|
"contacts_table_refresh": "Actualiser les contacts",
|
||||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||||
"create_attribute": "Créer un attribut",
|
"create_attribute": "Créer un attribut",
|
||||||
"create_key": "Créer une clé",
|
|
||||||
"create_new_attribute": "Créer un nouvel attribut",
|
"create_new_attribute": "Créer un nouvel attribut",
|
||||||
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
|
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
|
||||||
"custom_attributes": "Attributs personnalisés",
|
"custom_attributes": "Attributs personnalisés",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"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_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": "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.}}",
|
"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": "Modifier l'attribut",
|
||||||
"edit_attribute_description": "Mettez à jour l'étiquette et la description de cet attribut.",
|
"edit_attribute_description": "Mettez à jour l'étiquette et la description de cet attribut.",
|
||||||
"edit_attribute_values": "Modifier les attributs",
|
"edit_attribute_values": "Modifier les attributs",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"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_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_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
|
||||||
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre 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_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_published_surveys": "Aucune enquête publiée",
|
||||||
"no_responses_found": "Aucune réponse trouvée",
|
"no_responses_found": "Aucune réponse trouvée",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Sélectionner une enquête",
|
"select_a_survey": "Sélectionner une enquête",
|
||||||
"select_attribute": "Sélectionner un attribut",
|
"select_attribute": "Sélectionner un attribut",
|
||||||
"select_attribute_key": "Sélectionner une clé d'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",
|
"system_attributes": "Attributs système",
|
||||||
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
|
"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.",
|
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Lien Google Sheet",
|
"link_google_sheet": "Lien Google Sheet",
|
||||||
"link_new_sheet": "Lier une nouvelle feuille",
|
"link_new_sheet": "Lier une nouvelle feuille",
|
||||||
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
|
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
|
||||||
"spreadsheet_url": "URL de la feuille de calcul"
|
"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."
|
||||||
},
|
},
|
||||||
"include_created_at": "Inclure la date de création",
|
"include_created_at": "Inclure la date de création",
|
||||||
"include_hidden_fields": "Inclure les champs cachés",
|
"include_hidden_fields": "Inclure les champs cachés",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Réponses filtrées (Excel)",
|
"filtered_responses_excel": "Réponses filtrées (Excel)",
|
||||||
"generating_qr_code": "Génération du code QR",
|
"generating_qr_code": "Génération du code QR",
|
||||||
"impressions": "Impressions",
|
"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.",
|
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "Le sondage sera affiché aux utilisateurs de votre site web, qui correspondent aux critères listés ci-dessous",
|
"connection_description": "Le sondage sera affiché aux utilisateurs de votre site web, qui correspondent aux critères listés ci-dessous",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "dernier trimestre",
|
"last_quarter": "dernier trimestre",
|
||||||
"last_year": "l'année dernière",
|
"last_year": "l'année dernière",
|
||||||
"limit": "Limite",
|
"limit": "Limite",
|
||||||
|
"no_identified_impressions": "Aucune impression des contacts identifiés",
|
||||||
"no_responses_found": "Aucune réponse trouvée",
|
"no_responses_found": "Aucune réponse trouvée",
|
||||||
"other_values_found": "D'autres valeurs trouvées",
|
"other_values_found": "D'autres valeurs trouvées",
|
||||||
"overall": "Globalement",
|
"overall": "Globalement",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
|
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
|
||||||
"advanced_styling_field_headline_weight": "Graisse de police du titre",
|
"advanced_styling_field_headline_weight": "Graisse de police du titre",
|
||||||
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
|
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
|
||||||
"advanced_styling_field_height": "Hauteur",
|
"advanced_styling_field_height": "Hauteur minimale",
|
||||||
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
|
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
|
||||||
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
|
"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_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_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
|
||||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur 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_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_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.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Afficher la signature « Propulsé par Formbricks »",
|
"show_powered_by_formbricks": "Afficher la signature « Propulsé par Formbricks »",
|
||||||
"styling_updated_successfully": "Style mis à jour avec succès",
|
"styling_updated_successfully": "Style mis à jour avec succès",
|
||||||
"suggest_colors": "Suggérer des couleurs",
|
"suggest_colors": "Suggérer des couleurs",
|
||||||
|
"suggested_colors_applied_please_save": "Couleurs suggérées générées avec succès. Appuyez sur « Enregistrer » pour conserver les modifications.",
|
||||||
"theme": "Thème",
|
"theme": "Thème",
|
||||||
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
|
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Hiba",
|
"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_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_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_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",
|
"error_rate_limit_title": "A sebességkorlát elérve",
|
||||||
"expand_rows": "Sorok kinyitása",
|
"expand_rows": "Sorok kinyitása",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||||
"finish": "Befejezés",
|
"finish": "Befejezés",
|
||||||
|
"first_name": "Keresztnév",
|
||||||
"follow_these": "Ezek követése",
|
"follow_these": "Ezek követése",
|
||||||
"formbricks_version": "Formbricks verziója",
|
"formbricks_version": "Formbricks verziója",
|
||||||
"full_name": "Teljes név",
|
"full_name": "Teljes név",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Rejtett mező",
|
"hidden_field": "Rejtett mező",
|
||||||
"hidden_fields": "Rejtett mezők",
|
"hidden_fields": "Rejtett mezők",
|
||||||
"hide_column": "Oszlop elrejtése",
|
"hide_column": "Oszlop elrejtése",
|
||||||
|
"id": "ID",
|
||||||
"image": "Kép",
|
"image": "Kép",
|
||||||
"images": "Képek",
|
"images": "Képek",
|
||||||
"import": "Importálás",
|
"import": "Importálás",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Kulcs",
|
"key": "Kulcs",
|
||||||
"label": "Címke",
|
"label": "Címke",
|
||||||
"language": "Nyelv",
|
"language": "Nyelv",
|
||||||
|
"last_name": "Vezetéknév",
|
||||||
"learn_more": "Tudjon meg többet",
|
"learn_more": "Tudjon meg többet",
|
||||||
"license_expired": "A licenc lejárt",
|
"license_expired": "A licenc lejárt",
|
||||||
"light_overlay": "Világos rávetítés",
|
"light_overlay": "Világos rávetítés",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Jobbra fent",
|
"top_right": "Jobbra fent",
|
||||||
"try_again": "Próbálja újra",
|
"try_again": "Próbálja újra",
|
||||||
"type": "Típus",
|
"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.",
|
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
|
||||||
"update": "Frissítés",
|
"update": "Frissítés",
|
||||||
"updated": "Frissítve",
|
"updated": "Frissítve",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Partnerek frissítése",
|
"contacts_table_refresh": "Partnerek frissítése",
|
||||||
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
|
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
|
||||||
"create_attribute": "Attribútum létrehozása",
|
"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": "Új attribútum létrehozása",
|
||||||
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
|
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
|
||||||
"custom_attributes": "Egyéni attribútumok",
|
"custom_attributes": "Egyéni attribútumok",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"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_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": "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.}}",
|
"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": "Attribútum szerkesztése",
|
||||||
"edit_attribute_description": "Az attribútum címkéjének és leírásának frissíté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",
|
"edit_attribute_values": "Attribútumok szerkesztése",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"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_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_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.",
|
"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_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_published_surveys": "Nincsenek közzétett kérdőívek",
|
||||||
"no_responses_found": "Nem találhatók válaszok",
|
"no_responses_found": "Nem találhatók válaszok",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Kérdőív kiválasztása",
|
"select_a_survey": "Kérdőív kiválasztása",
|
||||||
"select_attribute": "Attribútum kiválasztása",
|
"select_attribute": "Attribútum kiválasztása",
|
||||||
"select_attribute_key": "Attribútum kulcs 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",
|
"system_attributes": "Rendszer attribútumok",
|
||||||
"unlock_contacts_description": "Partnerek kezelése és célzott kérdőívek kiküldése",
|
"unlock_contacts_description": "Partnerek kezelése és célzott kérdőívek kiküldése",
|
||||||
"unlock_contacts_title": "Partnerek feloldása egy magasabb csomaggal",
|
"unlock_contacts_title": "Partnerek feloldása egy magasabb csomaggal",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Google Táblázatok összekapcsolása",
|
"link_google_sheet": "Google Táblázatok összekapcsolása",
|
||||||
"link_new_sheet": "Új táblázat ö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. ⏲️",
|
"no_integrations_yet": "A Google Táblázatok integrációi itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
|
||||||
"spreadsheet_url": "Táblázat URL-e"
|
"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."
|
||||||
},
|
},
|
||||||
"include_created_at": "Létrehozva felvétele",
|
"include_created_at": "Létrehozva felvétele",
|
||||||
"include_hidden_fields": "Rejtett mezők felvétele",
|
"include_hidden_fields": "Rejtett mezők felvétele",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Szűrt válaszok (Excel)",
|
"filtered_responses_excel": "Szűrt válaszok (Excel)",
|
||||||
"generating_qr_code": "QR-kód előállítása",
|
"generating_qr_code": "QR-kód előállítása",
|
||||||
"impressions": "Benyomások",
|
"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.",
|
"impressions_tooltip": "A kérdőív megtekintési alkalmainak száma.",
|
||||||
"in_app": {
|
"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",
|
"connection_description": "A kérdőív a webhelye azon felhasználóinak lesz megjelenítve, akik megfelelnek az alább felsorolt feltételeknek",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Elmúlt negyedév",
|
"last_quarter": "Elmúlt negyedév",
|
||||||
"last_year": "Elmúlt év",
|
"last_year": "Elmúlt év",
|
||||||
"limit": "Korlát",
|
"limit": "Korlát",
|
||||||
|
"no_identified_impressions": "Nincsenek megjelenítések azonosított kapcsolatoktól",
|
||||||
"no_responses_found": "Nem találhatók válaszok",
|
"no_responses_found": "Nem találhatók válaszok",
|
||||||
"other_values_found": "Más értékek találhatók",
|
"other_values_found": "Más értékek találhatók",
|
||||||
"overall": "Összesen",
|
"overall": "Összesen",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
|
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
|
||||||
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
|
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
|
||||||
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
|
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
|
||||||
"advanced_styling_field_height": "Magasság",
|
"advanced_styling_field_height": "Minimális magasság",
|
||||||
"advanced_styling_field_indicator_bg": "Jelző háttere",
|
"advanced_styling_field_indicator_bg": "Jelző háttere",
|
||||||
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
|
"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_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_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
|
||||||
"advanced_styling_field_input_height_description": "A beviteli mező magasságát vezérli.",
|
"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_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_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.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Az „A gépházban: Formbricks” aláírás megjelenítése",
|
"show_powered_by_formbricks": "Az „A gépházban: Formbricks” aláírás megjelenítése",
|
||||||
"styling_updated_successfully": "A stílus sikeresen frissítve",
|
"styling_updated_successfully": "A stílus sikeresen frissítve",
|
||||||
"suggest_colors": "Színek ajánlása",
|
"suggest_colors": "Színek ajánlása",
|
||||||
|
"suggested_colors_applied_please_save": "A javasolt színek sikeresen generálva. Nyomd meg a \"Mentés\" gombot a változtatások véglegesítéséhez.",
|
||||||
"theme": "Téma",
|
"theme": "Téma",
|
||||||
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
|
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "エラー",
|
"error": "エラー",
|
||||||
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
|
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
|
||||||
"error_component_title": "リソース の 読み込み エラー",
|
"error_component_title": "リソース の 読み込み エラー",
|
||||||
|
"error_loading_data": "データの読み込みエラー",
|
||||||
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
|
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
|
||||||
"error_rate_limit_title": "レート制限を超えました",
|
"error_rate_limit_title": "レート制限を超えました",
|
||||||
"expand_rows": "行を展開",
|
"expand_rows": "行を展開",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||||
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
||||||
"finish": "完了",
|
"finish": "完了",
|
||||||
|
"first_name": "名",
|
||||||
"follow_these": "こちらの手順に従って",
|
"follow_these": "こちらの手順に従って",
|
||||||
"formbricks_version": "Formbricksバージョン",
|
"formbricks_version": "Formbricksバージョン",
|
||||||
"full_name": "氏名",
|
"full_name": "氏名",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "非表示フィールド",
|
"hidden_field": "非表示フィールド",
|
||||||
"hidden_fields": "非表示フィールド",
|
"hidden_fields": "非表示フィールド",
|
||||||
"hide_column": "列を非表示",
|
"hide_column": "列を非表示",
|
||||||
|
"id": "ID",
|
||||||
"image": "画像",
|
"image": "画像",
|
||||||
"images": "画像",
|
"images": "画像",
|
||||||
"import": "インポート",
|
"import": "インポート",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "キー",
|
"key": "キー",
|
||||||
"label": "ラベル",
|
"label": "ラベル",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
|
"last_name": "姓",
|
||||||
"learn_more": "詳細を見る",
|
"learn_more": "詳細を見る",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "明るいオーバーレイ",
|
"light_overlay": "明るいオーバーレイ",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "右上",
|
"top_right": "右上",
|
||||||
"try_again": "もう一度お試しください",
|
"try_again": "もう一度お試しください",
|
||||||
"type": "種類",
|
"type": "種類",
|
||||||
|
"unknown_survey": "不明なフォーム",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
|
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updated": "更新済み",
|
"updated": "更新済み",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "連絡先を更新",
|
"contacts_table_refresh": "連絡先を更新",
|
||||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||||
"create_attribute": "属性を作成",
|
"create_attribute": "属性を作成",
|
||||||
"create_key": "キーを作成",
|
|
||||||
"create_new_attribute": "新しい属性を作成",
|
"create_new_attribute": "新しい属性を作成",
|
||||||
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
|
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
|
||||||
"custom_attributes": "カスタム属性",
|
"custom_attributes": "カスタム属性",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {選択した属性を削除します。この属性に関連付けられたすべてのコンタクトデータは失われます。} other {選択した属性を削除します。これらの属性に関連付けられたすべてのコンタクトデータは失われます。}}",
|
"delete_attribute_confirmation": "{value, plural, one {選択した属性を削除します。この属性に関連付けられたすべてのコンタクトデータは失われます。} other {選択した属性を削除します。これらの属性に関連付けられたすべてのコンタクトデータは失われます。}}",
|
||||||
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
|
||||||
|
"displays": "表示回数",
|
||||||
"edit_attribute": "属性を編集",
|
"edit_attribute": "属性を編集",
|
||||||
"edit_attribute_description": "この属性のラベルと説明を更新します。",
|
"edit_attribute_description": "この属性のラベルと説明を更新します。",
|
||||||
"edit_attribute_values": "属性を編集",
|
"edit_attribute_values": "属性を編集",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
|
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
|
||||||
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
|
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
|
||||||
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
|
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
|
||||||
|
"no_activity_yet": "まだアクティビティがありません",
|
||||||
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
|
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
|
||||||
"no_published_surveys": "公開されたフォームはありません",
|
"no_published_surveys": "公開されたフォームはありません",
|
||||||
"no_responses_found": "回答が見つかりません",
|
"no_responses_found": "回答が見つかりません",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "フォームを選択",
|
"select_a_survey": "フォームを選択",
|
||||||
"select_attribute": "属性を選択",
|
"select_attribute": "属性を選択",
|
||||||
"select_attribute_key": "属性キーを選択",
|
"select_attribute_key": "属性キーを選択",
|
||||||
|
"survey_viewed": "フォームを閲覧",
|
||||||
|
"survey_viewed_at": "閲覧日時",
|
||||||
"system_attributes": "システム属性",
|
"system_attributes": "システム属性",
|
||||||
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
|
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
|
||||||
"unlock_contacts_title": "上位プランで連絡先をアンロック",
|
"unlock_contacts_title": "上位プランで連絡先をアンロック",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "スプレッドシートをリンク",
|
"link_google_sheet": "スプレッドシートをリンク",
|
||||||
"link_new_sheet": "新しいシートをリンク",
|
"link_new_sheet": "新しいシートをリンク",
|
||||||
"no_integrations_yet": "Google スプレッドシート連携は、追加するとここに表示されます。⏲️",
|
"no_integrations_yet": "Google スプレッドシート連携は、追加するとここに表示されます。⏲️",
|
||||||
"spreadsheet_url": "スプレッドシートURL"
|
"reconnect_button": "再接続",
|
||||||
|
"reconnect_button_description": "Google Sheetsの接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のスプレッドシートリンクとデータは保持されます。",
|
||||||
|
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のスプレッドシートリンクとデータは保持されます。",
|
||||||
|
"spreadsheet_permission_error": "このスプレッドシートにアクセスする権限がありません。スプレッドシートがGoogleアカウントと共有されており、書き込みアクセス権があることを確認してください。",
|
||||||
|
"spreadsheet_url": "スプレッドシートURL",
|
||||||
|
"token_expired_error": "Google Sheetsのリフレッシュトークンが期限切れになったか、取り消されました。統合を再接続してください。"
|
||||||
},
|
},
|
||||||
"include_created_at": "作成日時を含める",
|
"include_created_at": "作成日時を含める",
|
||||||
"include_hidden_fields": "非表示フィールドを含める",
|
"include_hidden_fields": "非表示フィールドを含める",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "フィルター済み回答 (Excel)",
|
"filtered_responses_excel": "フィルター済み回答 (Excel)",
|
||||||
"generating_qr_code": "QRコードを生成中",
|
"generating_qr_code": "QRコードを生成中",
|
||||||
"impressions": "表示回数",
|
"impressions": "表示回数",
|
||||||
|
"impressions_identified_only": "識別済みコンタクトからのインプレッションのみを表示しています",
|
||||||
"impressions_tooltip": "フォームが表示された回数。",
|
"impressions_tooltip": "フォームが表示された回数。",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "このフォームは、以下の条件に一致するあなたのウェブサイトのユーザーに表示されます",
|
"connection_description": "このフォームは、以下の条件に一致するあなたのウェブサイトのユーザーに表示されます",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "前四半期",
|
"last_quarter": "前四半期",
|
||||||
"last_year": "昨年",
|
"last_year": "昨年",
|
||||||
"limit": "制限",
|
"limit": "制限",
|
||||||
|
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
|
||||||
"no_responses_found": "回答が見つかりません",
|
"no_responses_found": "回答が見つかりません",
|
||||||
"other_values_found": "他の値が見つかりました",
|
"other_values_found": "他の値が見つかりました",
|
||||||
"overall": "全体",
|
"overall": "全体",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
|
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
|
||||||
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
|
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
|
||||||
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
|
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
|
||||||
"advanced_styling_field_height": "高さ",
|
"advanced_styling_field_height": "最小の高さ",
|
||||||
"advanced_styling_field_indicator_bg": "インジケーターの背景",
|
"advanced_styling_field_indicator_bg": "インジケーターの背景",
|
||||||
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
|
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
|
||||||
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
|
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
|
||||||
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
|
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
|
||||||
"advanced_styling_field_input_height_description": "入力フィールドの高さを調整します。",
|
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
|
||||||
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
|
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
|
||||||
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
|
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
|
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
|
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
|
||||||
"styling_updated_successfully": "スタイルを正常に更新しました",
|
"styling_updated_successfully": "スタイルを正常に更新しました",
|
||||||
"suggest_colors": "カラーを提案",
|
"suggest_colors": "カラーを提案",
|
||||||
|
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには「保存」を押してください。",
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
|
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Fout",
|
"error": "Fout",
|
||||||
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
|
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
|
||||||
"error_component_title": "Fout bij het laden van bronnen",
|
"error_component_title": "Fout bij het laden van bronnen",
|
||||||
|
"error_loading_data": "Fout bij het laden van gegevens",
|
||||||
"error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.",
|
"error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.",
|
||||||
"error_rate_limit_title": "Tarieflimiet overschreden",
|
"error_rate_limit_title": "Tarieflimiet overschreden",
|
||||||
"expand_rows": "Vouw rijen uit",
|
"expand_rows": "Vouw rijen uit",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
||||||
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
|
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
|
||||||
"finish": "Finish",
|
"finish": "Finish",
|
||||||
|
"first_name": "Voornaam",
|
||||||
"follow_these": "Volg deze",
|
"follow_these": "Volg deze",
|
||||||
"formbricks_version": "Formbricks-versie",
|
"formbricks_version": "Formbricks-versie",
|
||||||
"full_name": "Volledige naam",
|
"full_name": "Volledige naam",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Verborgen veld",
|
"hidden_field": "Verborgen veld",
|
||||||
"hidden_fields": "Verborgen velden",
|
"hidden_fields": "Verborgen velden",
|
||||||
"hide_column": "Kolom verbergen",
|
"hide_column": "Kolom verbergen",
|
||||||
|
"id": "ID",
|
||||||
"image": "Afbeelding",
|
"image": "Afbeelding",
|
||||||
"images": "Afbeeldingen",
|
"images": "Afbeeldingen",
|
||||||
"import": "Importeren",
|
"import": "Importeren",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Sleutel",
|
"key": "Sleutel",
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
"language": "Taal",
|
"language": "Taal",
|
||||||
|
"last_name": "Achternaam",
|
||||||
"learn_more": "Meer informatie",
|
"learn_more": "Meer informatie",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Lichte overlay",
|
"light_overlay": "Lichte overlay",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Rechtsboven",
|
"top_right": "Rechtsboven",
|
||||||
"try_again": "Probeer het opnieuw",
|
"try_again": "Probeer het opnieuw",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
"unknown_survey": "Onbekende enquête",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
|
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updated": "Bijgewerkt",
|
"updated": "Bijgewerkt",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Vernieuw contacten",
|
"contacts_table_refresh": "Vernieuw contacten",
|
||||||
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
|
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
|
||||||
"create_attribute": "Attribuut aanmaken",
|
"create_attribute": "Attribuut aanmaken",
|
||||||
"create_key": "Sleutel aanmaken",
|
|
||||||
"create_new_attribute": "Nieuw attribuut aanmaken",
|
"create_new_attribute": "Nieuw attribuut aanmaken",
|
||||||
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
|
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
|
||||||
"custom_attributes": "Aangepaste kenmerken",
|
"custom_attributes": "Aangepaste kenmerken",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {Dit verwijdert het geselecteerde attribuut. Alle contactgegevens die aan dit attribuut zijn gekoppeld, gaan verloren.} other {Dit verwijdert de geselecteerde attributen. Alle contactgegevens die aan deze attributen zijn gekoppeld, gaan verloren.}}",
|
"delete_attribute_confirmation": "{value, plural, one {Dit verwijdert het geselecteerde attribuut. Alle contactgegevens die aan dit attribuut zijn gekoppeld, gaan verloren.} other {Dit verwijdert de geselecteerde attributen. Alle contactgegevens die aan deze attributen zijn gekoppeld, gaan verloren.}}",
|
||||||
"delete_contact_confirmation": "Hierdoor worden alle enquêtereacties en contactkenmerken verwijderd die aan dit contact zijn gekoppeld. Elke targeting en personalisatie op basis van de gegevens van dit contact gaat verloren.",
|
"delete_contact_confirmation": "Hierdoor worden alle enquêtereacties en contactkenmerken verwijderd die aan dit contact zijn gekoppeld. Elke targeting en personalisatie op basis van de gegevens van dit contact gaat verloren.",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dit verwijdert alle enquêteresultaten en contactattributen die aan dit contact zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van dit contact gaan verloren. Als dit contact reacties heeft die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.} other {Dit verwijdert alle enquêteresultaten en contactattributen die aan deze contacten zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van deze contacten gaan verloren. Als deze contacten reacties hebben die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dit verwijdert alle enquêteresultaten en contactattributen die aan dit contact zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van dit contact gaan verloren. Als dit contact reacties heeft die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.} other {Dit verwijdert alle enquêteresultaten en contactattributen die aan deze contacten zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van deze contacten gaan verloren. Als deze contacten reacties hebben die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.}}",
|
||||||
|
"displays": "Weergaven",
|
||||||
"edit_attribute": "Attribuut bewerken",
|
"edit_attribute": "Attribuut bewerken",
|
||||||
"edit_attribute_description": "Werk het label en de beschrijving voor dit attribuut bij.",
|
"edit_attribute_description": "Werk het label en de beschrijving voor dit attribuut bij.",
|
||||||
"edit_attribute_values": "Attributen bewerken",
|
"edit_attribute_values": "Attributen bewerken",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
|
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
|
||||||
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
|
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
|
||||||
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
|
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
|
||||||
|
"no_activity_yet": "Nog geen activiteit",
|
||||||
"no_published_link_surveys_available": "Geen gepubliceerde link-enquêtes beschikbaar. Publiceer eerst een link-enquête.",
|
"no_published_link_surveys_available": "Geen gepubliceerde link-enquêtes beschikbaar. Publiceer eerst een link-enquête.",
|
||||||
"no_published_surveys": "Geen gepubliceerde enquêtes",
|
"no_published_surveys": "Geen gepubliceerde enquêtes",
|
||||||
"no_responses_found": "Geen reacties gevonden",
|
"no_responses_found": "Geen reacties gevonden",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Selecteer een enquête",
|
"select_a_survey": "Selecteer een enquête",
|
||||||
"select_attribute": "Selecteer Kenmerk",
|
"select_attribute": "Selecteer Kenmerk",
|
||||||
"select_attribute_key": "Selecteer kenmerksleutel",
|
"select_attribute_key": "Selecteer kenmerksleutel",
|
||||||
|
"survey_viewed": "Enquête bekeken",
|
||||||
|
"survey_viewed_at": "Bekeken op",
|
||||||
"system_attributes": "Systeemkenmerken",
|
"system_attributes": "Systeemkenmerken",
|
||||||
"unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes",
|
"unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes",
|
||||||
"unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement",
|
"unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Link Google Spreadsheet",
|
"link_google_sheet": "Link Google Spreadsheet",
|
||||||
"link_new_sheet": "Nieuw blad koppelen",
|
"link_new_sheet": "Nieuw blad koppelen",
|
||||||
"no_integrations_yet": "Uw Google Spreadsheet-integraties verschijnen hier zodra u ze toevoegt. ⏲️",
|
"no_integrations_yet": "Uw Google Spreadsheet-integraties verschijnen hier zodra u ze toevoegt. ⏲️",
|
||||||
"spreadsheet_url": "Spreadsheet-URL"
|
"reconnect_button": "Maak opnieuw verbinding",
|
||||||
|
"reconnect_button_description": "Je Google Sheets-verbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van antwoorden. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
|
||||||
|
"reconnect_button_tooltip": "Maak opnieuw verbinding met de integratie om je toegang te vernieuwen. Je bestaande spreadsheetlinks en gegevens blijven behouden.",
|
||||||
|
"spreadsheet_permission_error": "Je hebt geen toestemming om deze spreadsheet te openen. Zorg ervoor dat de spreadsheet is gedeeld met je Google-account en dat je schrijftoegang hebt tot de spreadsheet.",
|
||||||
|
"spreadsheet_url": "Spreadsheet-URL",
|
||||||
|
"token_expired_error": "Het vernieuwingstoken van Google Sheets is verlopen of ingetrokken. Maak opnieuw verbinding met de integratie."
|
||||||
},
|
},
|
||||||
"include_created_at": "Inclusief gemaakt op",
|
"include_created_at": "Inclusief gemaakt op",
|
||||||
"include_hidden_fields": "Inclusief verborgen velden",
|
"include_hidden_fields": "Inclusief verborgen velden",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Gefilterde reacties (Excel)",
|
"filtered_responses_excel": "Gefilterde reacties (Excel)",
|
||||||
"generating_qr_code": "QR-code genereren",
|
"generating_qr_code": "QR-code genereren",
|
||||||
"impressions": "Indrukken",
|
"impressions": "Indrukken",
|
||||||
|
"impressions_identified_only": "Alleen weergaven van geïdentificeerde contacten worden getoond",
|
||||||
"impressions_tooltip": "Aantal keren dat de enquête is bekeken.",
|
"impressions_tooltip": "Aantal keren dat de enquête is bekeken.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "De enquête wordt getoond aan gebruikers van uw website die voldoen aan de onderstaande criteria",
|
"connection_description": "De enquête wordt getoond aan gebruikers van uw website die voldoen aan de onderstaande criteria",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Laatste kwartaal",
|
"last_quarter": "Laatste kwartaal",
|
||||||
"last_year": "Vorig jaar",
|
"last_year": "Vorig jaar",
|
||||||
"limit": "Beperken",
|
"limit": "Beperken",
|
||||||
|
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
|
||||||
"no_responses_found": "Geen reacties gevonden",
|
"no_responses_found": "Geen reacties gevonden",
|
||||||
"other_values_found": "Andere waarden gevonden",
|
"other_values_found": "Andere waarden gevonden",
|
||||||
"overall": "Algemeen",
|
"overall": "Algemeen",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
|
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
|
||||||
"advanced_styling_field_headline_weight": "Letterdikte kop",
|
"advanced_styling_field_headline_weight": "Letterdikte kop",
|
||||||
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
|
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
|
||||||
"advanced_styling_field_height": "Hoogte",
|
"advanced_styling_field_height": "Minimale hoogte",
|
||||||
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
|
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
|
||||||
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
|
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
|
||||||
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
|
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
|
||||||
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
|
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
|
||||||
"advanced_styling_field_input_height_description": "Bepaalt de hoogte van het invoerveld.",
|
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
|
||||||
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
|
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
|
||||||
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
|
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Toon 'Powered by Formbricks' handtekening",
|
"show_powered_by_formbricks": "Toon 'Powered by Formbricks' handtekening",
|
||||||
"styling_updated_successfully": "Styling succesvol bijgewerkt",
|
"styling_updated_successfully": "Styling succesvol bijgewerkt",
|
||||||
"suggest_colors": "Kleuren voorstellen",
|
"suggest_colors": "Kleuren voorstellen",
|
||||||
|
"suggested_colors_applied_please_save": "Voorgestelde kleuren succesvol gegenereerd. Druk op \"Opslaan\" om de wijzigingen te behouden.",
|
||||||
"theme": "Thema",
|
"theme": "Thema",
|
||||||
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
|
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Erro",
|
"error": "Erro",
|
||||||
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
||||||
"error_component_title": "Erro ao carregar recursos",
|
"error_component_title": "Erro ao carregar recursos",
|
||||||
|
"error_loading_data": "Erro ao carregar dados",
|
||||||
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
|
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
|
||||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||||
"expand_rows": "Expandir linhas",
|
"expand_rows": "Expandir linhas",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||||
"finish": "Terminar",
|
"finish": "Terminar",
|
||||||
|
"first_name": "Primeiro nome",
|
||||||
"follow_these": "Siga esses",
|
"follow_these": "Siga esses",
|
||||||
"formbricks_version": "Versão do Formbricks",
|
"formbricks_version": "Versão do Formbricks",
|
||||||
"full_name": "Nome completo",
|
"full_name": "Nome completo",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Campo oculto",
|
"hidden_field": "Campo oculto",
|
||||||
"hidden_fields": "Campos ocultos",
|
"hidden_fields": "Campos ocultos",
|
||||||
"hide_column": "Ocultar coluna",
|
"hide_column": "Ocultar coluna",
|
||||||
|
"id": "ID",
|
||||||
"image": "imagem",
|
"image": "imagem",
|
||||||
"images": "Imagens",
|
"images": "Imagens",
|
||||||
"import": "importar",
|
"import": "importar",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Chave",
|
"key": "Chave",
|
||||||
"label": "Etiqueta",
|
"label": "Etiqueta",
|
||||||
"language": "Língua",
|
"language": "Língua",
|
||||||
|
"last_name": "Sobrenome",
|
||||||
"learn_more": "Saiba mais",
|
"learn_more": "Saiba mais",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "sobreposição leve",
|
"light_overlay": "sobreposição leve",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Canto Superior Direito",
|
"top_right": "Canto Superior Direito",
|
||||||
"try_again": "Tenta de novo",
|
"try_again": "Tenta de novo",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
|
"unknown_survey": "Pesquisa desconhecida",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
||||||
"update": "atualizar",
|
"update": "atualizar",
|
||||||
"updated": "atualizado",
|
"updated": "atualizado",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Atualizar contatos",
|
"contacts_table_refresh": "Atualizar contatos",
|
||||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||||
"create_attribute": "Criar atributo",
|
"create_attribute": "Criar atributo",
|
||||||
"create_key": "Criar chave",
|
|
||||||
"create_new_attribute": "Criar novo atributo",
|
"create_new_attribute": "Criar novo atributo",
|
||||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||||
"custom_attributes": "Atributos personalizados",
|
"custom_attributes": "Atributos personalizados",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {Isso excluirá o atributo selecionado. Todos os dados de contato associados a este atributo serão perdidos.} other {Isso excluirá os atributos selecionados. Todos os dados de contato associados a estes atributos serão perdidos.}}",
|
"delete_attribute_confirmation": "{value, plural, one {Isso excluirá o atributo selecionado. Todos os dados de contato associados a este atributo serão perdidos.} other {Isso excluirá os atributos selecionados. Todos os dados de contato associados a estes atributos serão perdidos.}}",
|
||||||
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
|
||||||
|
"displays": "Exibições",
|
||||||
"edit_attribute": "Editar atributo",
|
"edit_attribute": "Editar atributo",
|
||||||
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
||||||
"edit_attribute_values": "Editar atributos",
|
"edit_attribute_values": "Editar atributos",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
|
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
|
||||||
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
|
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
|
||||||
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
|
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
|
||||||
|
"no_activity_yet": "Nenhuma atividade ainda",
|
||||||
"no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.",
|
"no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.",
|
||||||
"no_published_surveys": "Sem pesquisas publicadas",
|
"no_published_surveys": "Sem pesquisas publicadas",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Selecione uma pesquisa",
|
"select_a_survey": "Selecione uma pesquisa",
|
||||||
"select_attribute": "Selecionar Atributo",
|
"select_attribute": "Selecionar Atributo",
|
||||||
"select_attribute_key": "Selecionar chave de atributo",
|
"select_attribute_key": "Selecionar chave de atributo",
|
||||||
|
"survey_viewed": "Pesquisa visualizada",
|
||||||
|
"survey_viewed_at": "Visualizada em",
|
||||||
"system_attributes": "Atributos do sistema",
|
"system_attributes": "Atributos do sistema",
|
||||||
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
|
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
|
||||||
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
|
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Link da Planilha do Google",
|
"link_google_sheet": "Link da Planilha do Google",
|
||||||
"link_new_sheet": "Vincular nova planilha",
|
"link_new_sheet": "Vincular nova planilha",
|
||||||
"no_integrations_yet": "Suas integrações do Google Sheets vão aparecer aqui assim que você adicioná-las. ⏲️",
|
"no_integrations_yet": "Suas integrações do Google Sheets vão aparecer aqui assim que você adicioná-las. ⏲️",
|
||||||
"spreadsheet_url": "URL da planilha"
|
"reconnect_button": "Reconectar",
|
||||||
|
"reconnect_button_description": "Sua conexão com o Google Sheets expirou. Reconecte para continuar sincronizando respostas. Seus links de planilhas e dados existentes serão preservados.",
|
||||||
|
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links de planilhas e dados existentes serão preservados.",
|
||||||
|
"spreadsheet_permission_error": "Você não tem permissão para acessar esta planilha. Certifique-se de que a planilha está compartilhada com sua conta do Google e que você tem acesso de escrita à planilha.",
|
||||||
|
"spreadsheet_url": "URL da planilha",
|
||||||
|
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Reconecte a integração."
|
||||||
},
|
},
|
||||||
"include_created_at": "Incluir Data de Criação",
|
"include_created_at": "Incluir Data de Criação",
|
||||||
"include_hidden_fields": "Incluir Campos Ocultos",
|
"include_hidden_fields": "Incluir Campos Ocultos",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||||
"generating_qr_code": "Gerando código QR",
|
"generating_qr_code": "Gerando código QR",
|
||||||
"impressions": "Impressões",
|
"impressions": "Impressões",
|
||||||
|
"impressions_identified_only": "Mostrando apenas impressões de contatos identificados",
|
||||||
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
|
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "A pesquisa será exibida para usuários do seu site, que atendam aos critérios listados abaixo",
|
"connection_description": "A pesquisa será exibida para usuários do seu site, que atendam aos critérios listados abaixo",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Último trimestre",
|
"last_quarter": "Último trimestre",
|
||||||
"last_year": "Último ano",
|
"last_year": "Último ano",
|
||||||
"limit": "Limite",
|
"limit": "Limite",
|
||||||
|
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
"other_values_found": "Outros valores encontrados",
|
"other_values_found": "Outros valores encontrados",
|
||||||
"overall": "No geral",
|
"overall": "No geral",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
||||||
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
||||||
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
||||||
"advanced_styling_field_height": "Altura",
|
"advanced_styling_field_height": "Altura mínima",
|
||||||
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
||||||
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
||||||
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
||||||
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
||||||
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
|
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
|
||||||
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
||||||
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
|
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
|
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
|
||||||
"styling_updated_successfully": "Estilo atualizado com sucesso",
|
"styling_updated_successfully": "Estilo atualizado com sucesso",
|
||||||
"suggest_colors": "Sugerir cores",
|
"suggest_colors": "Sugerir cores",
|
||||||
|
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione \"Salvar\" para manter as alterações.",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
|
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Erro",
|
"error": "Erro",
|
||||||
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
||||||
"error_component_title": "Erro ao carregar recursos",
|
"error_component_title": "Erro ao carregar recursos",
|
||||||
|
"error_loading_data": "Erro ao carregar dados",
|
||||||
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
|
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
|
||||||
"error_rate_limit_title": "Limite de Taxa Excedido",
|
"error_rate_limit_title": "Limite de Taxa Excedido",
|
||||||
"expand_rows": "Expandir linhas",
|
"expand_rows": "Expandir linhas",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||||
"finish": "Concluir",
|
"finish": "Concluir",
|
||||||
|
"first_name": "Primeiro nome",
|
||||||
"follow_these": "Siga estes",
|
"follow_these": "Siga estes",
|
||||||
"formbricks_version": "Versão do Formbricks",
|
"formbricks_version": "Versão do Formbricks",
|
||||||
"full_name": "Nome completo",
|
"full_name": "Nome completo",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Campo oculto",
|
"hidden_field": "Campo oculto",
|
||||||
"hidden_fields": "Campos ocultos",
|
"hidden_fields": "Campos ocultos",
|
||||||
"hide_column": "Ocultar coluna",
|
"hide_column": "Ocultar coluna",
|
||||||
|
"id": "ID",
|
||||||
"image": "Imagem",
|
"image": "Imagem",
|
||||||
"images": "Imagens",
|
"images": "Imagens",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Chave",
|
"key": "Chave",
|
||||||
"label": "Etiqueta",
|
"label": "Etiqueta",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
|
"last_name": "Apelido",
|
||||||
"learn_more": "Saiba mais",
|
"learn_more": "Saiba mais",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Sobreposição leve",
|
"light_overlay": "Sobreposição leve",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Superior Direito",
|
"top_right": "Superior Direito",
|
||||||
"try_again": "Tente novamente",
|
"try_again": "Tente novamente",
|
||||||
"type": "Tipo",
|
"type": "Tipo",
|
||||||
|
"unknown_survey": "Inquérito desconhecido",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
|
||||||
"update": "Atualizar",
|
"update": "Atualizar",
|
||||||
"updated": "Atualizado",
|
"updated": "Atualizado",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Atualizar contactos",
|
"contacts_table_refresh": "Atualizar contactos",
|
||||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||||
"create_attribute": "Criar atributo",
|
"create_attribute": "Criar atributo",
|
||||||
"create_key": "Criar chave",
|
|
||||||
"create_new_attribute": "Criar novo atributo",
|
"create_new_attribute": "Criar novo atributo",
|
||||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||||
"custom_attributes": "Atributos personalizados",
|
"custom_attributes": "Atributos personalizados",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {Isto irá eliminar o atributo selecionado. Todos os dados de contacto associados a este atributo serão perdidos.} other {Isto irá eliminar os atributos selecionados. Todos os dados de contacto associados a estes atributos serão perdidos.}}",
|
"delete_attribute_confirmation": "{value, plural, one {Isto irá eliminar o atributo selecionado. Todos os dados de contacto associados a este atributo serão perdidos.} other {Isto irá eliminar os atributos selecionados. Todos os dados de contacto associados a estes atributos serão perdidos.}}",
|
||||||
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
|
||||||
|
"displays": "Visualizações",
|
||||||
"edit_attribute": "Editar atributo",
|
"edit_attribute": "Editar atributo",
|
||||||
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
||||||
"edit_attribute_values": "Editar atributos",
|
"edit_attribute_values": "Editar atributos",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
|
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
|
||||||
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
|
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
|
||||||
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
|
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
|
||||||
|
"no_activity_yet": "Ainda sem atividade",
|
||||||
"no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.",
|
"no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.",
|
||||||
"no_published_surveys": "Sem inquéritos publicados",
|
"no_published_surveys": "Sem inquéritos publicados",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Selecione um inquérito",
|
"select_a_survey": "Selecione um inquérito",
|
||||||
"select_attribute": "Selecionar Atributo",
|
"select_attribute": "Selecionar Atributo",
|
||||||
"select_attribute_key": "Selecionar chave de atributo",
|
"select_attribute_key": "Selecionar chave de atributo",
|
||||||
|
"survey_viewed": "Inquérito visualizado",
|
||||||
|
"survey_viewed_at": "Visualizado em",
|
||||||
"system_attributes": "Atributos do sistema",
|
"system_attributes": "Atributos do sistema",
|
||||||
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
|
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
|
||||||
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
|
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Ligar Folha do Google",
|
"link_google_sheet": "Ligar Folha do Google",
|
||||||
"link_new_sheet": "Ligar nova Folha",
|
"link_new_sheet": "Ligar nova Folha",
|
||||||
"no_integrations_yet": "As suas integrações com o Google Sheets aparecerão aqui assim que as adicionar. ⏲️",
|
"no_integrations_yet": "As suas integrações com o Google Sheets aparecerão aqui assim que as adicionar. ⏲️",
|
||||||
"spreadsheet_url": "URL da folha de cálculo"
|
"reconnect_button": "Reconectar",
|
||||||
|
"reconnect_button_description": "A tua ligação ao Google Sheets expirou. Por favor, reconecta para continuar a sincronizar respostas. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
|
||||||
|
"reconnect_button_tooltip": "Reconecta a integração para atualizar o teu acesso. As tuas ligações de folhas de cálculo e dados existentes serão preservados.",
|
||||||
|
"spreadsheet_permission_error": "Não tens permissão para aceder a esta folha de cálculo. Por favor, certifica-te de que a folha de cálculo está partilhada com a tua conta Google e que tens acesso de escrita à folha de cálculo.",
|
||||||
|
"spreadsheet_url": "URL da folha de cálculo",
|
||||||
|
"token_expired_error": "O token de atualização do Google Sheets expirou ou foi revogado. Por favor, reconecta a integração."
|
||||||
},
|
},
|
||||||
"include_created_at": "Incluir Criado Em",
|
"include_created_at": "Incluir Criado Em",
|
||||||
"include_hidden_fields": "Incluir Campos Ocultos",
|
"include_hidden_fields": "Incluir Campos Ocultos",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||||
"generating_qr_code": "A gerar código QR",
|
"generating_qr_code": "A gerar código QR",
|
||||||
"impressions": "Impressões",
|
"impressions": "Impressões",
|
||||||
|
"impressions_identified_only": "A mostrar apenas impressões de contactos identificados",
|
||||||
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
|
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "O questionário será exibido aos utilizadores do seu website que correspondam aos critérios listados abaixo",
|
"connection_description": "O questionário será exibido aos utilizadores do seu website que correspondam aos critérios listados abaixo",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Último trimestre",
|
"last_quarter": "Último trimestre",
|
||||||
"last_year": "Ano passado",
|
"last_year": "Ano passado",
|
||||||
"limit": "Limite",
|
"limit": "Limite",
|
||||||
|
"no_identified_impressions": "Sem impressões de contactos identificados",
|
||||||
"no_responses_found": "Nenhuma resposta encontrada",
|
"no_responses_found": "Nenhuma resposta encontrada",
|
||||||
"other_values_found": "Outros valores encontrados",
|
"other_values_found": "Outros valores encontrados",
|
||||||
"overall": "Geral",
|
"overall": "Geral",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
||||||
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
||||||
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
||||||
"advanced_styling_field_height": "Altura",
|
"advanced_styling_field_height": "Altura mínima",
|
||||||
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
||||||
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
||||||
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
||||||
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
||||||
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
|
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
|
||||||
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
||||||
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
|
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
|
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
|
||||||
"styling_updated_successfully": "Estilo atualizado com sucesso",
|
"styling_updated_successfully": "Estilo atualizado com sucesso",
|
||||||
"suggest_colors": "Sugerir cores",
|
"suggest_colors": "Sugerir cores",
|
||||||
|
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressiona \"Guardar\" para manter as alterações.",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
|
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Eroare",
|
"error": "Eroare",
|
||||||
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
|
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
|
||||||
"error_component_title": "Eroare la încărcarea resurselor",
|
"error_component_title": "Eroare la încărcarea resurselor",
|
||||||
|
"error_loading_data": "Eroare la încărcarea datelor",
|
||||||
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
|
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
|
||||||
"error_rate_limit_title": "Limită de cereri depășită",
|
"error_rate_limit_title": "Limită de cereri depășită",
|
||||||
"expand_rows": "Extinde rândurile",
|
"expand_rows": "Extinde rândurile",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||||
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
|
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
|
||||||
"finish": "Finalizează",
|
"finish": "Finalizează",
|
||||||
|
"first_name": "Prenume",
|
||||||
"follow_these": "Urmați acestea",
|
"follow_these": "Urmați acestea",
|
||||||
"formbricks_version": "Versiunea Formbricks",
|
"formbricks_version": "Versiunea Formbricks",
|
||||||
"full_name": "Nume complet",
|
"full_name": "Nume complet",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Câmp ascuns",
|
"hidden_field": "Câmp ascuns",
|
||||||
"hidden_fields": "Câmpuri ascunse",
|
"hidden_fields": "Câmpuri ascunse",
|
||||||
"hide_column": "Ascunde coloana",
|
"hide_column": "Ascunde coloana",
|
||||||
|
"id": "ID",
|
||||||
"image": "Imagine",
|
"image": "Imagine",
|
||||||
"images": "Imagini",
|
"images": "Imagini",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Cheie",
|
"key": "Cheie",
|
||||||
"label": "Etichetă",
|
"label": "Etichetă",
|
||||||
"language": "Limba",
|
"language": "Limba",
|
||||||
|
"last_name": "Nume de familie",
|
||||||
"learn_more": "Află mai multe",
|
"learn_more": "Află mai multe",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Suprapunere ușoară",
|
"light_overlay": "Suprapunere ușoară",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Dreapta Sus",
|
"top_right": "Dreapta Sus",
|
||||||
"try_again": "Încearcă din nou",
|
"try_again": "Încearcă din nou",
|
||||||
"type": "Tip",
|
"type": "Tip",
|
||||||
|
"unknown_survey": "Chestionar necunoscut",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
|
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
|
||||||
"update": "Actualizare",
|
"update": "Actualizare",
|
||||||
"updated": "Actualizat",
|
"updated": "Actualizat",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||||
"create_attribute": "Creează atribut",
|
"create_attribute": "Creează atribut",
|
||||||
"create_key": "Creează cheie",
|
|
||||||
"create_new_attribute": "Creează atribut nou",
|
"create_new_attribute": "Creează atribut nou",
|
||||||
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
|
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
|
||||||
"custom_attributes": "Atribute personalizate",
|
"custom_attributes": "Atribute personalizate",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {Acest lucru va șterge atributul selectat. Orice date de contact asociate cu acest atribut vor fi pierdute.} few {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.} other {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.}}",
|
"delete_attribute_confirmation": "{value, plural, one {Acest lucru va șterge atributul selectat. Orice date de contact asociate cu acest atribut vor fi pierdute.} few {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.} other {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.}}",
|
||||||
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
|
||||||
|
"displays": "Afișări",
|
||||||
"edit_attribute": "Editează atributul",
|
"edit_attribute": "Editează atributul",
|
||||||
"edit_attribute_description": "Actualizează eticheta și descrierea acestui atribut.",
|
"edit_attribute_description": "Actualizează eticheta și descrierea acestui atribut.",
|
||||||
"edit_attribute_values": "Editează atributele",
|
"edit_attribute_values": "Editează atributele",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
|
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
|
||||||
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
|
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
|
||||||
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
|
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
|
||||||
|
"no_activity_yet": "Nicio activitate încă",
|
||||||
"no_published_link_surveys_available": "Nu există sondaje publicate pentru linkuri disponibile. Vă rugăm să publicați mai întâi un sondaj pentru linkuri.",
|
"no_published_link_surveys_available": "Nu există sondaje publicate pentru linkuri disponibile. Vă rugăm să publicați mai întâi un sondaj pentru linkuri.",
|
||||||
"no_published_surveys": "Nu există sondaje publicate",
|
"no_published_surveys": "Nu există sondaje publicate",
|
||||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Selectați un sondaj",
|
"select_a_survey": "Selectați un sondaj",
|
||||||
"select_attribute": "Selectează atributul",
|
"select_attribute": "Selectează atributul",
|
||||||
"select_attribute_key": "Selectează cheia atributului",
|
"select_attribute_key": "Selectează cheia atributului",
|
||||||
|
"survey_viewed": "Chestionar vizualizat",
|
||||||
|
"survey_viewed_at": "Vizualizat la",
|
||||||
"system_attributes": "Atribute de sistem",
|
"system_attributes": "Atribute de sistem",
|
||||||
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
|
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
|
||||||
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
|
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Leagă Google Sheet",
|
"link_google_sheet": "Leagă Google Sheet",
|
||||||
"link_new_sheet": "Leagă un nou Sheet",
|
"link_new_sheet": "Leagă un nou Sheet",
|
||||||
"no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
"no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||||
"spreadsheet_url": "URL foaie de calcul"
|
"reconnect_button": "Reconectează",
|
||||||
|
"reconnect_button_description": "Conexiunea ta cu Google Sheets a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele existente din foile de calcul vor fi păstrate.",
|
||||||
|
"reconnect_button_tooltip": "Reconectează integrarea pentru a-ți reîmprospăta accesul. Linkurile și datele existente din foile de calcul vor fi păstrate.",
|
||||||
|
"spreadsheet_permission_error": "Nu ai permisiunea de a accesa această foaie de calcul. Asigură-te că foaia de calcul este partajată cu contul tău Google și că ai acces de scriere la aceasta.",
|
||||||
|
"spreadsheet_url": "URL foaie de calcul",
|
||||||
|
"token_expired_error": "Tokenul de reîmprospătare Google Sheets a expirat sau a fost revocat. Te rugăm să reconectezi integrarea."
|
||||||
},
|
},
|
||||||
"include_created_at": "Include data creării",
|
"include_created_at": "Include data creării",
|
||||||
"include_hidden_fields": "Include câmpuri ascunse",
|
"include_hidden_fields": "Include câmpuri ascunse",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Răspunsuri filtrate (Excel)",
|
"filtered_responses_excel": "Răspunsuri filtrate (Excel)",
|
||||||
"generating_qr_code": "Se generează codul QR",
|
"generating_qr_code": "Se generează codul QR",
|
||||||
"impressions": "Impresii",
|
"impressions": "Impresii",
|
||||||
|
"impressions_identified_only": "Se afișează doar impresiile de la contactele identificate",
|
||||||
"impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.",
|
"impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "Sondajul va fi afișat utilizatorilor site-ului dvs. web, care îndeplinesc criteriile enumerate mai jos",
|
"connection_description": "Sondajul va fi afișat utilizatorilor site-ului dvs. web, care îndeplinesc criteriile enumerate mai jos",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Ultimul trimestru",
|
"last_quarter": "Ultimul trimestru",
|
||||||
"last_year": "Anul trecut",
|
"last_year": "Anul trecut",
|
||||||
"limit": "Limită",
|
"limit": "Limită",
|
||||||
|
"no_identified_impressions": "Nicio impresie de la contactele identificate",
|
||||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||||
"other_values_found": "Alte valori găsite",
|
"other_values_found": "Alte valori găsite",
|
||||||
"overall": "General",
|
"overall": "General",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Scalează textul titlului.",
|
"advanced_styling_field_headline_size_description": "Scalează textul titlului.",
|
||||||
"advanced_styling_field_headline_weight": "Grosime font titlu",
|
"advanced_styling_field_headline_weight": "Grosime font titlu",
|
||||||
"advanced_styling_field_headline_weight_description": "Face textul titlului mai subțire sau mai îngroșat.",
|
"advanced_styling_field_headline_weight_description": "Face textul titlului mai subțire sau mai îngroșat.",
|
||||||
"advanced_styling_field_height": "Înălțime",
|
"advanced_styling_field_height": "Înălțime minimă",
|
||||||
"advanced_styling_field_indicator_bg": "Fundal indicator",
|
"advanced_styling_field_indicator_bg": "Fundal indicator",
|
||||||
"advanced_styling_field_indicator_bg_description": "Colorează partea umplută a barei.",
|
"advanced_styling_field_indicator_bg_description": "Colorează partea umplută a barei.",
|
||||||
"advanced_styling_field_input_border_radius_description": "Rotunjește colțurile câmpurilor de introducere.",
|
"advanced_styling_field_input_border_radius_description": "Rotunjește colțurile câmpurilor de introducere.",
|
||||||
"advanced_styling_field_input_font_size_description": "Scalează textul introdus în câmpuri.",
|
"advanced_styling_field_input_font_size_description": "Scalează textul introdus în câmpuri.",
|
||||||
"advanced_styling_field_input_height_description": "Controlează înălțimea câmpului de introducere.",
|
"advanced_styling_field_input_height_description": "Controlează înălțimea minimă a câmpului de introducere.",
|
||||||
"advanced_styling_field_input_padding_x_description": "Adaugă spațiu la stânga și la dreapta.",
|
"advanced_styling_field_input_padding_x_description": "Adaugă spațiu la stânga și la dreapta.",
|
||||||
"advanced_styling_field_input_padding_y_description": "Adaugă spațiu deasupra și dedesubt.",
|
"advanced_styling_field_input_padding_y_description": "Adaugă spațiu deasupra și dedesubt.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Estompează textul de sugestie din placeholder.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Estompează textul de sugestie din placeholder.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Afișează semnătura „Powered by Formbricks”",
|
"show_powered_by_formbricks": "Afișează semnătura „Powered by Formbricks”",
|
||||||
"styling_updated_successfully": "Stilizarea a fost actualizată cu succes",
|
"styling_updated_successfully": "Stilizarea a fost actualizată cu succes",
|
||||||
"suggest_colors": "Sugerează culori",
|
"suggest_colors": "Sugerează culori",
|
||||||
|
"suggested_colors_applied_please_save": "Culorile sugerate au fost generate cu succes. Apasă pe „Salvează” pentru a păstra modificările.",
|
||||||
"theme": "Temă",
|
"theme": "Temă",
|
||||||
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
|
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Ошибка",
|
"error": "Ошибка",
|
||||||
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
|
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
|
||||||
"error_component_title": "Ошибка загрузки ресурсов",
|
"error_component_title": "Ошибка загрузки ресурсов",
|
||||||
|
"error_loading_data": "Ошибка загрузки данных",
|
||||||
"error_rate_limit_description": "Достигнуто максимальное количество запросов. Пожалуйста, попробуйте позже.",
|
"error_rate_limit_description": "Достигнуто максимальное количество запросов. Пожалуйста, попробуйте позже.",
|
||||||
"error_rate_limit_title": "Превышен лимит запросов",
|
"error_rate_limit_title": "Превышен лимит запросов",
|
||||||
"expand_rows": "Развернуть строки",
|
"expand_rows": "Развернуть строки",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Не удалось загрузить организации",
|
"failed_to_load_organizations": "Не удалось загрузить организации",
|
||||||
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
||||||
"finish": "Завершить",
|
"finish": "Завершить",
|
||||||
|
"first_name": "Имя",
|
||||||
"follow_these": "Выполните следующие действия",
|
"follow_these": "Выполните следующие действия",
|
||||||
"formbricks_version": "Версия Formbricks",
|
"formbricks_version": "Версия Formbricks",
|
||||||
"full_name": "Полное имя",
|
"full_name": "Полное имя",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Скрытое поле",
|
"hidden_field": "Скрытое поле",
|
||||||
"hidden_fields": "Скрытые поля",
|
"hidden_fields": "Скрытые поля",
|
||||||
"hide_column": "Скрыть столбец",
|
"hide_column": "Скрыть столбец",
|
||||||
|
"id": "ID",
|
||||||
"image": "Изображение",
|
"image": "Изображение",
|
||||||
"images": "Изображения",
|
"images": "Изображения",
|
||||||
"import": "Импорт",
|
"import": "Импорт",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Ключ",
|
"key": "Ключ",
|
||||||
"label": "Метка",
|
"label": "Метка",
|
||||||
"language": "Язык",
|
"language": "Язык",
|
||||||
|
"last_name": "Фамилия",
|
||||||
"learn_more": "Подробнее",
|
"learn_more": "Подробнее",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Светлый оверлей",
|
"light_overlay": "Светлый оверлей",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Вверху справа",
|
"top_right": "Вверху справа",
|
||||||
"try_again": "Попробуйте ещё раз",
|
"try_again": "Попробуйте ещё раз",
|
||||||
"type": "Тип",
|
"type": "Тип",
|
||||||
|
"unknown_survey": "Неизвестный опрос",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
|
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
|
||||||
"update": "Обновить",
|
"update": "Обновить",
|
||||||
"updated": "Обновлено",
|
"updated": "Обновлено",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Обновить контакты",
|
"contacts_table_refresh": "Обновить контакты",
|
||||||
"contacts_table_refresh_success": "Контакты успешно обновлены",
|
"contacts_table_refresh_success": "Контакты успешно обновлены",
|
||||||
"create_attribute": "Создать атрибут",
|
"create_attribute": "Создать атрибут",
|
||||||
"create_key": "Создать ключ",
|
|
||||||
"create_new_attribute": "Создать новый атрибут",
|
"create_new_attribute": "Создать новый атрибут",
|
||||||
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
|
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
|
||||||
"custom_attributes": "Пользовательские атрибуты",
|
"custom_attributes": "Пользовательские атрибуты",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {Будет удалён выбранный атрибут. Все данные контактов, связанные с этим атрибутом, будут потеряны.} few {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} many {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} other {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.}}",
|
"delete_attribute_confirmation": "{value, plural, one {Будет удалён выбранный атрибут. Все данные контактов, связанные с этим атрибутом, будут потеряны.} few {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} many {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} other {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.}}",
|
||||||
"delete_contact_confirmation": "Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны.",
|
"delete_contact_confirmation": "Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны.",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны. Если у этого контакта есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} few {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} many {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} other {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны. Если у этого контакта есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} few {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} many {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} other {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.}}",
|
||||||
|
"displays": "Показы",
|
||||||
"edit_attribute": "Редактировать атрибут",
|
"edit_attribute": "Редактировать атрибут",
|
||||||
"edit_attribute_description": "Обновите метку и описание для этого атрибута.",
|
"edit_attribute_description": "Обновите метку и описание для этого атрибута.",
|
||||||
"edit_attribute_values": "Редактировать атрибуты",
|
"edit_attribute_values": "Редактировать атрибуты",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
|
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
|
||||||
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
|
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
|
||||||
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
|
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
|
||||||
|
"no_activity_yet": "Пока нет активности",
|
||||||
"no_published_link_surveys_available": "Нет доступных опубликованных опросов-ссылок. Пожалуйста, сначала опубликуйте опрос-ссылку.",
|
"no_published_link_surveys_available": "Нет доступных опубликованных опросов-ссылок. Пожалуйста, сначала опубликуйте опрос-ссылку.",
|
||||||
"no_published_surveys": "Нет опубликованных опросов",
|
"no_published_surveys": "Нет опубликованных опросов",
|
||||||
"no_responses_found": "Ответы не найдены",
|
"no_responses_found": "Ответы не найдены",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Выберите опрос",
|
"select_a_survey": "Выберите опрос",
|
||||||
"select_attribute": "Выберите атрибут",
|
"select_attribute": "Выберите атрибут",
|
||||||
"select_attribute_key": "Выберите ключ атрибута",
|
"select_attribute_key": "Выберите ключ атрибута",
|
||||||
|
"survey_viewed": "Опрос просмотрен",
|
||||||
|
"survey_viewed_at": "Просмотрено",
|
||||||
"system_attributes": "Системные атрибуты",
|
"system_attributes": "Системные атрибуты",
|
||||||
"unlock_contacts_description": "Управляйте контактами и отправляйте целевые опросы",
|
"unlock_contacts_description": "Управляйте контактами и отправляйте целевые опросы",
|
||||||
"unlock_contacts_title": "Откройте доступ к контактам с более высоким тарифом",
|
"unlock_contacts_title": "Откройте доступ к контактам с более высоким тарифом",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Связать с Google Sheet",
|
"link_google_sheet": "Связать с Google Sheet",
|
||||||
"link_new_sheet": "Связать с новой таблицей",
|
"link_new_sheet": "Связать с новой таблицей",
|
||||||
"no_integrations_yet": "Ваши интеграции с Google Sheet появятся здесь, как только вы их добавите. ⏲️",
|
"no_integrations_yet": "Ваши интеграции с Google Sheet появятся здесь, как только вы их добавите. ⏲️",
|
||||||
"spreadsheet_url": "URL таблицы"
|
"reconnect_button": "Переподключить",
|
||||||
|
"reconnect_button_description": "Срок действия подключения к Google Sheets истёк. Пожалуйста, переподключись, чтобы продолжить синхронизацию ответов. Все существующие ссылки на таблицы и данные будут сохранены.",
|
||||||
|
"reconnect_button_tooltip": "Переподключи интеграцию, чтобы обновить доступ. Все существующие ссылки на таблицы и данные будут сохранены.",
|
||||||
|
"spreadsheet_permission_error": "У тебя нет доступа к этой таблице. Убедись, что таблица открыта для твоего Google-аккаунта и у тебя есть права на запись.",
|
||||||
|
"spreadsheet_url": "URL таблицы",
|
||||||
|
"token_expired_error": "Срок действия токена обновления Google Sheets истёк или он был отозван. Пожалуйста, переподключи интеграцию."
|
||||||
},
|
},
|
||||||
"include_created_at": "Включить дату создания",
|
"include_created_at": "Включить дату создания",
|
||||||
"include_hidden_fields": "Включить скрытые поля",
|
"include_hidden_fields": "Включить скрытые поля",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Отфильтрованные ответы (Excel)",
|
"filtered_responses_excel": "Отфильтрованные ответы (Excel)",
|
||||||
"generating_qr_code": "Генерация QR-кода",
|
"generating_qr_code": "Генерация QR-кода",
|
||||||
"impressions": "Просмотры",
|
"impressions": "Просмотры",
|
||||||
|
"impressions_identified_only": "Показаны только показы от идентифицированных контактов",
|
||||||
"impressions_tooltip": "Количество раз, когда опрос был просмотрен.",
|
"impressions_tooltip": "Количество раз, когда опрос был просмотрен.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "Опрос будет показан пользователям вашего сайта, которые соответствуют указанным ниже критериям",
|
"connection_description": "Опрос будет показан пользователям вашего сайта, которые соответствуют указанным ниже критериям",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Прошлый квартал",
|
"last_quarter": "Прошлый квартал",
|
||||||
"last_year": "Прошлый год",
|
"last_year": "Прошлый год",
|
||||||
"limit": "Лимит",
|
"limit": "Лимит",
|
||||||
|
"no_identified_impressions": "Нет показов от идентифицированных контактов",
|
||||||
"no_responses_found": "Ответы не найдены",
|
"no_responses_found": "Ответы не найдены",
|
||||||
"other_values_found": "Найдены другие значения",
|
"other_values_found": "Найдены другие значения",
|
||||||
"overall": "В целом",
|
"overall": "В целом",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Масштабирует текст заголовка.",
|
"advanced_styling_field_headline_size_description": "Масштабирует текст заголовка.",
|
||||||
"advanced_styling_field_headline_weight": "Толщина шрифта заголовка",
|
"advanced_styling_field_headline_weight": "Толщина шрифта заголовка",
|
||||||
"advanced_styling_field_headline_weight_description": "Делает текст заголовка тоньше или жирнее.",
|
"advanced_styling_field_headline_weight_description": "Делает текст заголовка тоньше или жирнее.",
|
||||||
"advanced_styling_field_height": "Высота",
|
"advanced_styling_field_height": "Минимальная высота",
|
||||||
"advanced_styling_field_indicator_bg": "Фон индикатора",
|
"advanced_styling_field_indicator_bg": "Фон индикатора",
|
||||||
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
|
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
|
||||||
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
|
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
|
||||||
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
|
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
|
||||||
"advanced_styling_field_input_height_description": "Определяет высоту поля ввода.",
|
"advanced_styling_field_input_height_description": "Определяет минимальную высоту поля ввода.",
|
||||||
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
|
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
|
||||||
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
|
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Показывать подпись «Работает на Formbricks»",
|
"show_powered_by_formbricks": "Показывать подпись «Работает на Formbricks»",
|
||||||
"styling_updated_successfully": "Стили успешно обновлены",
|
"styling_updated_successfully": "Стили успешно обновлены",
|
||||||
"suggest_colors": "Предложить цвета",
|
"suggest_colors": "Предложить цвета",
|
||||||
|
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажми «Сохранить», чтобы применить изменения.",
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
|
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "Fel",
|
"error": "Fel",
|
||||||
"error_component_description": "Denna resurs finns inte eller så har du inte de nödvändiga rättigheterna för att komma åt den.",
|
"error_component_description": "Denna resurs finns inte eller så har du inte de nödvändiga rättigheterna för att komma åt den.",
|
||||||
"error_component_title": "Fel vid laddning av resurser",
|
"error_component_title": "Fel vid laddning av resurser",
|
||||||
|
"error_loading_data": "Fel vid inläsning av data",
|
||||||
"error_rate_limit_description": "Maximalt antal förfrågningar har nåtts. Försök igen senare.",
|
"error_rate_limit_description": "Maximalt antal förfrågningar har nåtts. Försök igen senare.",
|
||||||
"error_rate_limit_title": "Begränsningsgräns överskriden",
|
"error_rate_limit_title": "Begränsningsgräns överskriden",
|
||||||
"expand_rows": "Visa rader",
|
"expand_rows": "Visa rader",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
|
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
|
||||||
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
|
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
|
||||||
"finish": "Slutför",
|
"finish": "Slutför",
|
||||||
|
"first_name": "Förnamn",
|
||||||
"follow_these": "Följ dessa",
|
"follow_these": "Följ dessa",
|
||||||
"formbricks_version": "Formbricks-version",
|
"formbricks_version": "Formbricks-version",
|
||||||
"full_name": "Fullständigt namn",
|
"full_name": "Fullständigt namn",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "Dolt fält",
|
"hidden_field": "Dolt fält",
|
||||||
"hidden_fields": "Dolda fält",
|
"hidden_fields": "Dolda fält",
|
||||||
"hide_column": "Dölj kolumn",
|
"hide_column": "Dölj kolumn",
|
||||||
|
"id": "ID",
|
||||||
"image": "Bild",
|
"image": "Bild",
|
||||||
"images": "Bilder",
|
"images": "Bilder",
|
||||||
"import": "Importera",
|
"import": "Importera",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "Nyckel",
|
"key": "Nyckel",
|
||||||
"label": "Etikett",
|
"label": "Etikett",
|
||||||
"language": "Språk",
|
"language": "Språk",
|
||||||
|
"last_name": "Efternamn",
|
||||||
"learn_more": "Läs mer",
|
"learn_more": "Läs mer",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "Ljust överlägg",
|
"light_overlay": "Ljust överlägg",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "Övre höger",
|
"top_right": "Övre höger",
|
||||||
"try_again": "Försök igen",
|
"try_again": "Försök igen",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
|
"unknown_survey": "Okänd enkät",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.",
|
"unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.",
|
||||||
"update": "Uppdatera",
|
"update": "Uppdatera",
|
||||||
"updated": "Uppdaterad",
|
"updated": "Uppdaterad",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "Uppdatera kontakter",
|
"contacts_table_refresh": "Uppdatera kontakter",
|
||||||
"contacts_table_refresh_success": "Kontakter uppdaterade",
|
"contacts_table_refresh_success": "Kontakter uppdaterade",
|
||||||
"create_attribute": "Skapa attribut",
|
"create_attribute": "Skapa attribut",
|
||||||
"create_key": "Skapa nyckel",
|
|
||||||
"create_new_attribute": "Skapa nytt attribut",
|
"create_new_attribute": "Skapa nytt attribut",
|
||||||
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
|
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
|
||||||
"custom_attributes": "Anpassade attribut",
|
"custom_attributes": "Anpassade attribut",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {Detta kommer att ta bort det valda attributet. All kontaktdata som är kopplad till detta attribut kommer att gå förlorad.} other {Detta kommer att ta bort de valda attributen. All kontaktdata som är kopplad till dessa attribut kommer att gå förlorad.}}",
|
"delete_attribute_confirmation": "{value, plural, one {Detta kommer att ta bort det valda attributet. All kontaktdata som är kopplad till detta attribut kommer att gå förlorad.} other {Detta kommer att ta bort de valda attributen. All kontaktdata som är kopplad till dessa attribut kommer att gå förlorad.}}",
|
||||||
"delete_contact_confirmation": "Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad.",
|
"delete_contact_confirmation": "Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad.",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad. Om denna kontakt har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.} other {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till dessa kontakter. All målgruppsinriktning och personalisering baserad på dessa kontakters data kommer att gå förlorad. Om dessa kontakter har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad. Om denna kontakt har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.} other {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till dessa kontakter. All målgruppsinriktning och personalisering baserad på dessa kontakters data kommer att gå förlorad. Om dessa kontakter har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.}}",
|
||||||
|
"displays": "Visningar",
|
||||||
"edit_attribute": "Redigera attribut",
|
"edit_attribute": "Redigera attribut",
|
||||||
"edit_attribute_description": "Uppdatera etikett och beskrivning för detta attribut.",
|
"edit_attribute_description": "Uppdatera etikett och beskrivning för detta attribut.",
|
||||||
"edit_attribute_values": "Redigera attribut",
|
"edit_attribute_values": "Redigera attribut",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
|
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
|
||||||
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
|
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
|
||||||
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
|
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
|
||||||
|
"no_activity_yet": "Ingen aktivitet än",
|
||||||
"no_published_link_surveys_available": "Inga publicerade länkenkäter tillgängliga. Vänligen publicera en länkenkät först.",
|
"no_published_link_surveys_available": "Inga publicerade länkenkäter tillgängliga. Vänligen publicera en länkenkät först.",
|
||||||
"no_published_surveys": "Inga publicerade enkäter",
|
"no_published_surveys": "Inga publicerade enkäter",
|
||||||
"no_responses_found": "Inga svar hittades",
|
"no_responses_found": "Inga svar hittades",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "Välj en enkät",
|
"select_a_survey": "Välj en enkät",
|
||||||
"select_attribute": "Välj attribut",
|
"select_attribute": "Välj attribut",
|
||||||
"select_attribute_key": "Välj attributnyckel",
|
"select_attribute_key": "Välj attributnyckel",
|
||||||
|
"survey_viewed": "Enkät visad",
|
||||||
|
"survey_viewed_at": "Visad kl.",
|
||||||
"system_attributes": "Systemattribut",
|
"system_attributes": "Systemattribut",
|
||||||
"unlock_contacts_description": "Hantera kontakter och skicka ut riktade enkäter",
|
"unlock_contacts_description": "Hantera kontakter och skicka ut riktade enkäter",
|
||||||
"unlock_contacts_title": "Lås upp kontakter med en högre plan",
|
"unlock_contacts_title": "Lås upp kontakter med en högre plan",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "Länka Google Kalkylark",
|
"link_google_sheet": "Länka Google Kalkylark",
|
||||||
"link_new_sheet": "Länka nytt kalkylark",
|
"link_new_sheet": "Länka nytt kalkylark",
|
||||||
"no_integrations_yet": "Dina Google Kalkylark-integrationer visas här så snart du lägger till dem. ⏲️",
|
"no_integrations_yet": "Dina Google Kalkylark-integrationer visas här så snart du lägger till dem. ⏲️",
|
||||||
"spreadsheet_url": "Kalkylblads-URL"
|
"reconnect_button": "Återanslut",
|
||||||
|
"reconnect_button_description": "Din Google Sheets-anslutning har gått ut. Återanslut för att fortsätta synkronisera svar. Dina befintliga kalkylarkslänkar och data kommer att sparas.",
|
||||||
|
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga kalkylarkslänkar och data kommer att sparas.",
|
||||||
|
"spreadsheet_permission_error": "Du har inte behörighet att komma åt det här kalkylarket. Kontrollera att kalkylarket är delat med ditt Google-konto och att du har skrivrättigheter till kalkylarket.",
|
||||||
|
"spreadsheet_url": "Kalkylblads-URL",
|
||||||
|
"token_expired_error": "Google Sheets refresh token har gått ut eller återkallats. Återanslut integrationen."
|
||||||
},
|
},
|
||||||
"include_created_at": "Inkludera Skapad vid",
|
"include_created_at": "Inkludera Skapad vid",
|
||||||
"include_hidden_fields": "Inkludera dolda fält",
|
"include_hidden_fields": "Inkludera dolda fält",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "Filtrerade svar (Excel)",
|
"filtered_responses_excel": "Filtrerade svar (Excel)",
|
||||||
"generating_qr_code": "Genererar QR-kod",
|
"generating_qr_code": "Genererar QR-kod",
|
||||||
"impressions": "Visningar",
|
"impressions": "Visningar",
|
||||||
|
"impressions_identified_only": "Visar bara visningar från identifierade kontakter",
|
||||||
"impressions_tooltip": "Antal gånger enkäten har visats.",
|
"impressions_tooltip": "Antal gånger enkäten har visats.",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "Enkäten kommer att visas för användare på din webbplats som matchar kriterierna nedan",
|
"connection_description": "Enkäten kommer att visas för användare på din webbplats som matchar kriterierna nedan",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "Senaste kvartalet",
|
"last_quarter": "Senaste kvartalet",
|
||||||
"last_year": "Senaste året",
|
"last_year": "Senaste året",
|
||||||
"limit": "Gräns",
|
"limit": "Gräns",
|
||||||
|
"no_identified_impressions": "Inga visningar från identifierade kontakter",
|
||||||
"no_responses_found": "Inga svar hittades",
|
"no_responses_found": "Inga svar hittades",
|
||||||
"other_values_found": "Andra värden hittades",
|
"other_values_found": "Andra värden hittades",
|
||||||
"overall": "Övergripande",
|
"overall": "Övergripande",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "Ändrar storleken på rubriken.",
|
"advanced_styling_field_headline_size_description": "Ändrar storleken på rubriken.",
|
||||||
"advanced_styling_field_headline_weight": "Rubrikens teckentjocklek",
|
"advanced_styling_field_headline_weight": "Rubrikens teckentjocklek",
|
||||||
"advanced_styling_field_headline_weight_description": "Gör rubriktexten tunnare eller fetare.",
|
"advanced_styling_field_headline_weight_description": "Gör rubriktexten tunnare eller fetare.",
|
||||||
"advanced_styling_field_height": "Höjd",
|
"advanced_styling_field_height": "Minsta höjd",
|
||||||
"advanced_styling_field_indicator_bg": "Indikatorns bakgrund",
|
"advanced_styling_field_indicator_bg": "Indikatorns bakgrund",
|
||||||
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
|
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
|
||||||
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
|
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
|
||||||
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
|
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
|
||||||
"advanced_styling_field_input_height_description": "Styr höjden på inmatningsfältet.",
|
"advanced_styling_field_input_height_description": "Styr den minsta höjden på inmatningsfältet.",
|
||||||
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
|
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
|
||||||
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
|
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
|
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "Visa 'Powered by Formbricks'-signatur",
|
"show_powered_by_formbricks": "Visa 'Powered by Formbricks'-signatur",
|
||||||
"styling_updated_successfully": "Stiluppdatering lyckades",
|
"styling_updated_successfully": "Stiluppdatering lyckades",
|
||||||
"suggest_colors": "Föreslå färger",
|
"suggest_colors": "Föreslå färger",
|
||||||
|
"suggested_colors_applied_please_save": "Föreslagna färger har skapats. Tryck på \"Spara\" för att spara ändringarna.",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning."
|
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "错误",
|
"error": "错误",
|
||||||
"error_component_description": "这个资源不存在或您没有权限访问它。",
|
"error_component_description": "这个资源不存在或您没有权限访问它。",
|
||||||
"error_component_title": "错误 加载 资源",
|
"error_component_title": "错误 加载 资源",
|
||||||
|
"error_loading_data": "数据加载出错",
|
||||||
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
|
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
|
||||||
"error_rate_limit_title": "速率 限制 超过",
|
"error_rate_limit_title": "速率 限制 超过",
|
||||||
"expand_rows": "展开 行",
|
"expand_rows": "展开 行",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "加载组织失败",
|
"failed_to_load_organizations": "加载组织失败",
|
||||||
"failed_to_load_workspaces": "加载工作区失败",
|
"failed_to_load_workspaces": "加载工作区失败",
|
||||||
"finish": "完成",
|
"finish": "完成",
|
||||||
|
"first_name": "名字",
|
||||||
"follow_these": "遵循 这些",
|
"follow_these": "遵循 这些",
|
||||||
"formbricks_version": "Formbricks 版本",
|
"formbricks_version": "Formbricks 版本",
|
||||||
"full_name": "全名",
|
"full_name": "全名",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "隐藏 字段",
|
"hidden_field": "隐藏 字段",
|
||||||
"hidden_fields": "隐藏 字段",
|
"hidden_fields": "隐藏 字段",
|
||||||
"hide_column": "隐藏 列",
|
"hide_column": "隐藏 列",
|
||||||
|
"id": "ID",
|
||||||
"image": "图片",
|
"image": "图片",
|
||||||
"images": "图片",
|
"images": "图片",
|
||||||
"import": "导入",
|
"import": "导入",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "键",
|
"key": "键",
|
||||||
"label": "标签",
|
"label": "标签",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
|
"last_name": "姓",
|
||||||
"learn_more": "了解 更多",
|
"learn_more": "了解 更多",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "浅色遮罩层",
|
"light_overlay": "浅色遮罩层",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "右上",
|
"top_right": "右上",
|
||||||
"try_again": "再试一次",
|
"try_again": "再试一次",
|
||||||
"type": "类型",
|
"type": "类型",
|
||||||
|
"unknown_survey": "未知调查",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
|
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updated": "已更新",
|
"updated": "已更新",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "刷新 联系人",
|
"contacts_table_refresh": "刷新 联系人",
|
||||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||||
"create_attribute": "创建属性",
|
"create_attribute": "创建属性",
|
||||||
"create_key": "创建键",
|
|
||||||
"create_new_attribute": "创建新属性",
|
"create_new_attribute": "创建新属性",
|
||||||
"create_new_attribute_description": "为细分目的创建新属性。",
|
"create_new_attribute_description": "为细分目的创建新属性。",
|
||||||
"custom_attributes": "自定义属性",
|
"custom_attributes": "自定义属性",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {这将删除所选属性。与该属性相关的任何联系人数据都将丢失。} other {这将删除所选属性。与这些属性相关的任何联系人数据都将丢失。}}",
|
"delete_attribute_confirmation": "{value, plural, one {这将删除所选属性。与该属性相关的任何联系人数据都将丢失。} other {这将删除所选属性。与这些属性相关的任何联系人数据都将丢失。}}",
|
||||||
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
|
||||||
|
"displays": "展示次数",
|
||||||
"edit_attribute": "编辑属性",
|
"edit_attribute": "编辑属性",
|
||||||
"edit_attribute_description": "更新此属性的标签和描述。",
|
"edit_attribute_description": "更新此属性的标签和描述。",
|
||||||
"edit_attribute_values": "编辑属性",
|
"edit_attribute_values": "编辑属性",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
|
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
|
||||||
"invalid_date_format": "日期格式无效。请使用有效日期。",
|
"invalid_date_format": "日期格式无效。请使用有效日期。",
|
||||||
"invalid_number_format": "数字格式无效。请输入有效的数字。",
|
"invalid_number_format": "数字格式无效。请输入有效的数字。",
|
||||||
|
"no_activity_yet": "暂无活动",
|
||||||
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",
|
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",
|
||||||
"no_published_surveys": "没有已发布的调查",
|
"no_published_surveys": "没有已发布的调查",
|
||||||
"no_responses_found": "未找到 响应",
|
"no_responses_found": "未找到 响应",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "选择一个调查",
|
"select_a_survey": "选择一个调查",
|
||||||
"select_attribute": "选择 属性",
|
"select_attribute": "选择 属性",
|
||||||
"select_attribute_key": "选择属性键",
|
"select_attribute_key": "选择属性键",
|
||||||
|
"survey_viewed": "已查看调查",
|
||||||
|
"survey_viewed_at": "查看时间",
|
||||||
"system_attributes": "系统属性",
|
"system_attributes": "系统属性",
|
||||||
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
|
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
|
||||||
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
|
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "链接 Google 表格",
|
"link_google_sheet": "链接 Google 表格",
|
||||||
"link_new_sheet": "链接 新 表格",
|
"link_new_sheet": "链接 新 表格",
|
||||||
"no_integrations_yet": "您的 Google Sheet 集成会在您 添加 后 出现在这里。 ⏲️",
|
"no_integrations_yet": "您的 Google Sheet 集成会在您 添加 后 出现在这里。 ⏲️",
|
||||||
"spreadsheet_url": "电子表格 URL"
|
"reconnect_button": "重新连接",
|
||||||
|
"reconnect_button_description": "你的 Google Sheets 连接已过期。请重新连接以继续同步回复。你现有的表格链接和数据会被保留。",
|
||||||
|
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的表格链接和数据会被保留。",
|
||||||
|
"spreadsheet_permission_error": "你没有权限访问此表格。请确保该表格已与你的 Google 账号共享,并且你拥有该表格的编辑权限。",
|
||||||
|
"spreadsheet_url": "电子表格 URL",
|
||||||
|
"token_expired_error": "Google Sheets 的刷新令牌已过期或被撤销。请重新连接集成。"
|
||||||
},
|
},
|
||||||
"include_created_at": "包括 创建 于",
|
"include_created_at": "包括 创建 于",
|
||||||
"include_hidden_fields": "包括 隐藏 字段",
|
"include_hidden_fields": "包括 隐藏 字段",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "过滤 反馈 (Excel)",
|
"filtered_responses_excel": "过滤 反馈 (Excel)",
|
||||||
"generating_qr_code": "正在生成二维码",
|
"generating_qr_code": "正在生成二维码",
|
||||||
"impressions": "印象",
|
"impressions": "印象",
|
||||||
|
"impressions_identified_only": "仅显示已识别联系人的展示次数",
|
||||||
"impressions_tooltip": "调查 被 查看 的 次数",
|
"impressions_tooltip": "调查 被 查看 的 次数",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "调查将显示给符合以下条件的您网站用户",
|
"connection_description": "调查将显示给符合以下条件的您网站用户",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "上季度",
|
"last_quarter": "上季度",
|
||||||
"last_year": "去年",
|
"last_year": "去年",
|
||||||
"limit": "限额",
|
"limit": "限额",
|
||||||
|
"no_identified_impressions": "没有已识别联系人的展示次数",
|
||||||
"no_responses_found": "未找到响应",
|
"no_responses_found": "未找到响应",
|
||||||
"other_values_found": "找到其他值",
|
"other_values_found": "找到其他值",
|
||||||
"overall": "整体",
|
"overall": "整体",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "调整主标题文字大小。",
|
"advanced_styling_field_headline_size_description": "调整主标题文字大小。",
|
||||||
"advanced_styling_field_headline_weight": "标题字体粗细",
|
"advanced_styling_field_headline_weight": "标题字体粗细",
|
||||||
"advanced_styling_field_headline_weight_description": "设置主标题文字的粗细。",
|
"advanced_styling_field_headline_weight_description": "设置主标题文字的粗细。",
|
||||||
"advanced_styling_field_height": "高度",
|
"advanced_styling_field_height": "最小高度",
|
||||||
"advanced_styling_field_indicator_bg": "指示器背景",
|
"advanced_styling_field_indicator_bg": "指示器背景",
|
||||||
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
|
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
|
||||||
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
|
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
|
||||||
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
|
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
|
||||||
"advanced_styling_field_input_height_description": "控制输入框高度。",
|
"advanced_styling_field_input_height_description": "设置输入框的最小高度。",
|
||||||
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
|
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
|
||||||
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
|
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
|
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "显示“Powered by Formbricks”标识",
|
"show_powered_by_formbricks": "显示“Powered by Formbricks”标识",
|
||||||
"styling_updated_successfully": "样式更新成功",
|
"styling_updated_successfully": "样式更新成功",
|
||||||
"suggest_colors": "推荐颜色",
|
"suggest_colors": "推荐颜色",
|
||||||
|
"suggested_colors_applied_please_save": "已成功生成推荐配色。请点击“保存”以保留更改。",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
|
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"error": "錯誤",
|
"error": "錯誤",
|
||||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||||
"error_component_title": "載入資源錯誤",
|
"error_component_title": "載入資源錯誤",
|
||||||
|
"error_loading_data": "載入資料時發生錯誤",
|
||||||
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
|
||||||
"error_rate_limit_title": "限流超過",
|
"error_rate_limit_title": "限流超過",
|
||||||
"expand_rows": "展開列",
|
"expand_rows": "展開列",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"failed_to_load_organizations": "無法載入組織",
|
"failed_to_load_organizations": "無法載入組織",
|
||||||
"failed_to_load_workspaces": "載入工作區失敗",
|
"failed_to_load_workspaces": "載入工作區失敗",
|
||||||
"finish": "完成",
|
"finish": "完成",
|
||||||
|
"first_name": "名字",
|
||||||
"follow_these": "按照這些步驟",
|
"follow_these": "按照這些步驟",
|
||||||
"formbricks_version": "Formbricks 版本",
|
"formbricks_version": "Formbricks 版本",
|
||||||
"full_name": "全名",
|
"full_name": "全名",
|
||||||
@@ -237,6 +239,7 @@
|
|||||||
"hidden_field": "隱藏欄位",
|
"hidden_field": "隱藏欄位",
|
||||||
"hidden_fields": "隱藏欄位",
|
"hidden_fields": "隱藏欄位",
|
||||||
"hide_column": "隱藏欄位",
|
"hide_column": "隱藏欄位",
|
||||||
|
"id": "ID",
|
||||||
"image": "圖片",
|
"image": "圖片",
|
||||||
"images": "圖片",
|
"images": "圖片",
|
||||||
"import": "匯入",
|
"import": "匯入",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"key": "金鑰",
|
"key": "金鑰",
|
||||||
"label": "標籤",
|
"label": "標籤",
|
||||||
"language": "語言",
|
"language": "語言",
|
||||||
|
"last_name": "姓氏",
|
||||||
"learn_more": "瞭解更多",
|
"learn_more": "瞭解更多",
|
||||||
"license_expired": "License Expired",
|
"license_expired": "License Expired",
|
||||||
"light_overlay": "淺色覆蓋",
|
"light_overlay": "淺色覆蓋",
|
||||||
@@ -428,6 +432,7 @@
|
|||||||
"top_right": "右上",
|
"top_right": "右上",
|
||||||
"try_again": "再試一次",
|
"try_again": "再試一次",
|
||||||
"type": "類型",
|
"type": "類型",
|
||||||
|
"unknown_survey": "未知問卷",
|
||||||
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
|
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updated": "已更新",
|
"updated": "已更新",
|
||||||
@@ -645,7 +650,6 @@
|
|||||||
"contacts_table_refresh": "重新整理聯絡人",
|
"contacts_table_refresh": "重新整理聯絡人",
|
||||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||||
"create_attribute": "建立屬性",
|
"create_attribute": "建立屬性",
|
||||||
"create_key": "建立金鑰",
|
|
||||||
"create_new_attribute": "建立新屬性",
|
"create_new_attribute": "建立新屬性",
|
||||||
"create_new_attribute_description": "建立新屬性以進行分群用途。",
|
"create_new_attribute_description": "建立新屬性以進行分群用途。",
|
||||||
"custom_attributes": "自訂屬性",
|
"custom_attributes": "自訂屬性",
|
||||||
@@ -656,6 +660,7 @@
|
|||||||
"delete_attribute_confirmation": "{value, plural, one {這將刪除所選屬性。與此屬性相關的聯絡人資料將會遺失。} other {這將刪除所選屬性。與這些屬性相關的聯絡人資料將會遺失。}}",
|
"delete_attribute_confirmation": "{value, plural, one {這將刪除所選屬性。與此屬性相關的聯絡人資料將會遺失。} other {這將刪除所選屬性。與這些屬性相關的聯絡人資料將會遺失。}}",
|
||||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
|
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
|
||||||
|
"displays": "顯示次數",
|
||||||
"edit_attribute": "編輯屬性",
|
"edit_attribute": "編輯屬性",
|
||||||
"edit_attribute_description": "更新此屬性的標籤與描述。",
|
"edit_attribute_description": "更新此屬性的標籤與描述。",
|
||||||
"edit_attribute_values": "編輯屬性",
|
"edit_attribute_values": "編輯屬性",
|
||||||
@@ -667,6 +672,7 @@
|
|||||||
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
|
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
|
||||||
"invalid_date_format": "日期格式無效。請使用有效的日期。",
|
"invalid_date_format": "日期格式無效。請使用有效的日期。",
|
||||||
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
|
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
|
||||||
|
"no_activity_yet": "尚無活動",
|
||||||
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
|
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
|
||||||
"no_published_surveys": "沒有已發佈的問卷",
|
"no_published_surveys": "沒有已發佈的問卷",
|
||||||
"no_responses_found": "找不到回應",
|
"no_responses_found": "找不到回應",
|
||||||
@@ -681,6 +687,8 @@
|
|||||||
"select_a_survey": "選擇問卷",
|
"select_a_survey": "選擇問卷",
|
||||||
"select_attribute": "選取屬性",
|
"select_attribute": "選取屬性",
|
||||||
"select_attribute_key": "選取屬性鍵值",
|
"select_attribute_key": "選取屬性鍵值",
|
||||||
|
"survey_viewed": "已查看問卷",
|
||||||
|
"survey_viewed_at": "查看時間",
|
||||||
"system_attributes": "系統屬性",
|
"system_attributes": "系統屬性",
|
||||||
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
|
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
|
||||||
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
|
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
|
||||||
@@ -752,7 +760,12 @@
|
|||||||
"link_google_sheet": "連結 Google 試算表",
|
"link_google_sheet": "連結 Google 試算表",
|
||||||
"link_new_sheet": "連結新試算表",
|
"link_new_sheet": "連結新試算表",
|
||||||
"no_integrations_yet": "您的 Google 試算表整合將在您新增後立即顯示在此處。⏲️",
|
"no_integrations_yet": "您的 Google 試算表整合將在您新增後立即顯示在此處。⏲️",
|
||||||
"spreadsheet_url": "試算表網址"
|
"reconnect_button": "重新連線",
|
||||||
|
"reconnect_button_description": "你的 Google Sheets 連線已過期。請重新連線以繼續同步回應。你現有的試算表連結和資料都會被保留。",
|
||||||
|
"reconnect_button_tooltip": "重新連線整合以刷新存取權限。你現有的試算表連結和資料都會被保留。",
|
||||||
|
"spreadsheet_permission_error": "你沒有權限存取這個試算表。請確認該試算表已與你的 Google 帳戶分享,且你擁有寫入權限。",
|
||||||
|
"spreadsheet_url": "試算表網址",
|
||||||
|
"token_expired_error": "Google Sheets 的刷新權杖已過期或被撤銷。請重新連線整合。"
|
||||||
},
|
},
|
||||||
"include_created_at": "包含建立於",
|
"include_created_at": "包含建立於",
|
||||||
"include_hidden_fields": "包含隱藏欄位",
|
"include_hidden_fields": "包含隱藏欄位",
|
||||||
@@ -1947,6 +1960,7 @@
|
|||||||
"filtered_responses_excel": "篩選回應 (Excel)",
|
"filtered_responses_excel": "篩選回應 (Excel)",
|
||||||
"generating_qr_code": "正在生成 QR code",
|
"generating_qr_code": "正在生成 QR code",
|
||||||
"impressions": "曝光數",
|
"impressions": "曝光數",
|
||||||
|
"impressions_identified_only": "僅顯示已識別聯絡人的曝光次數",
|
||||||
"impressions_tooltip": "問卷已檢視的次數。",
|
"impressions_tooltip": "問卷已檢視的次數。",
|
||||||
"in_app": {
|
"in_app": {
|
||||||
"connection_description": "調查將顯示給符合以下列出條件的網站用戶",
|
"connection_description": "調查將顯示給符合以下列出條件的網站用戶",
|
||||||
@@ -1989,6 +2003,7 @@
|
|||||||
"last_quarter": "上一季",
|
"last_quarter": "上一季",
|
||||||
"last_year": "去年",
|
"last_year": "去年",
|
||||||
"limit": "限制",
|
"limit": "限制",
|
||||||
|
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
|
||||||
"no_responses_found": "找不到回應",
|
"no_responses_found": "找不到回應",
|
||||||
"other_values_found": "找到其他值",
|
"other_values_found": "找到其他值",
|
||||||
"overall": "整體",
|
"overall": "整體",
|
||||||
@@ -2153,12 +2168,12 @@
|
|||||||
"advanced_styling_field_headline_size_description": "調整標題文字的大小。",
|
"advanced_styling_field_headline_size_description": "調整標題文字的大小。",
|
||||||
"advanced_styling_field_headline_weight": "標題字體粗細",
|
"advanced_styling_field_headline_weight": "標題字體粗細",
|
||||||
"advanced_styling_field_headline_weight_description": "讓標題文字變細或變粗。",
|
"advanced_styling_field_headline_weight_description": "讓標題文字變細或變粗。",
|
||||||
"advanced_styling_field_height": "高度",
|
"advanced_styling_field_height": "最小高度",
|
||||||
"advanced_styling_field_indicator_bg": "指示器背景",
|
"advanced_styling_field_indicator_bg": "指示器背景",
|
||||||
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
|
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
|
||||||
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
|
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
|
||||||
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
|
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
|
||||||
"advanced_styling_field_input_height_description": "調整輸入欄位的高度。",
|
"advanced_styling_field_input_height_description": "設定輸入欄位的最小高度。",
|
||||||
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
|
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
|
||||||
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
|
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
|
||||||
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
|
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
|
||||||
@@ -2221,6 +2236,7 @@
|
|||||||
"show_powered_by_formbricks": "顯示「Powered by Formbricks」標記",
|
"show_powered_by_formbricks": "顯示「Powered by Formbricks」標記",
|
||||||
"styling_updated_successfully": "樣式已成功更新",
|
"styling_updated_successfully": "樣式已成功更新",
|
||||||
"suggest_colors": "建議顏色",
|
"suggest_colors": "建議顏色",
|
||||||
|
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「儲存」以保存變更。",
|
||||||
"theme": "主題",
|
"theme": "主題",
|
||||||
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
|
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
|
|||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData([], mockResponseData, "en", true, mockQuestions);
|
validateResponseData([], mockResponseData, "en", mockQuestions);
|
||||||
|
|
||||||
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
||||||
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
||||||
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
|
|||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
|
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
|
||||||
|
|
||||||
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return null when both blocks and questions are empty", () => {
|
test("should return null when both blocks and questions are empty", () => {
|
||||||
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
|
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
|
||||||
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
|
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
|
||||||
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
|
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should use default language code", () => {
|
test("should use default language code", () => {
|
||||||
@@ -125,25 +125,58 @@ describe("validateResponseData", () => {
|
|||||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
|
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate only present fields when finished is false", () => {
|
test("should validate only fields present in responseData", () => {
|
||||||
const partialResponseData: TResponseData = { element1: "test" };
|
const partialResponseData: TResponseData = { element1: "test" };
|
||||||
const partialElements = [mockElements[0]];
|
const elementsToValidate = [mockElements[0]];
|
||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData(mockBlocks, partialResponseData, "en", false);
|
validateResponseData(mockBlocks, partialResponseData, "en");
|
||||||
|
|
||||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
|
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate all fields when finished is true", () => {
|
test("should never validate elements not in responseData", () => {
|
||||||
const partialResponseData: TResponseData = { element1: "test" };
|
const blocksWithTwoElements: TSurveyBlock[] = [
|
||||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
...mockBlocks,
|
||||||
|
{
|
||||||
|
id: "block2",
|
||||||
|
name: "Block 2",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: "element2",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Q2" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: { enabled: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const allElements = [
|
||||||
|
...mockElements,
|
||||||
|
{
|
||||||
|
id: "element2",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Q2" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: { enabled: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const responseDataWithOnlyElement1: TResponseData = { element1: "test" };
|
||||||
|
mockGetElementsFromBlocks.mockReturnValue(allElements);
|
||||||
mockValidateBlockResponses.mockReturnValue({});
|
mockValidateBlockResponses.mockReturnValue({});
|
||||||
|
|
||||||
validateResponseData(mockBlocks, partialResponseData, "en", true);
|
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
|
||||||
|
|
||||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
|
// Only element1 should be validated, not element2 (even though it's required)
|
||||||
|
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
|
||||||
|
[allElements[0]],
|
||||||
|
responseDataWithOnlyElement1,
|
||||||
|
"en"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|||||||
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates response data against survey validation rules
|
* Validates response data against survey validation rules.
|
||||||
* Handles partial responses (in-progress) by only validating present fields when finished is false
|
* Only validates elements that have data in responseData - never validates
|
||||||
|
* all survey elements regardless of completion status.
|
||||||
*
|
*
|
||||||
* @param blocks - Survey blocks containing elements with validation rules (preferred)
|
* @param blocks - Survey blocks containing elements with validation rules (preferred)
|
||||||
* @param responseData - Response data to validate (keyed by element ID)
|
* @param responseData - Response data to validate (keyed by element ID)
|
||||||
* @param languageCode - Language code for error messages (defaults to "en")
|
* @param languageCode - Language code for error messages (defaults to "en")
|
||||||
* @param finished - Whether the response is finished (defaults to true for management APIs)
|
|
||||||
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
|
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
|
||||||
* @returns Validation error map keyed by element ID, or null if validation passes
|
* @returns Validation error map keyed by element ID, or null if validation passes
|
||||||
*/
|
*/
|
||||||
@@ -23,7 +23,6 @@ export const validateResponseData = (
|
|||||||
blocks: TSurveyBlock[] | undefined | null,
|
blocks: TSurveyBlock[] | undefined | null,
|
||||||
responseData: TResponseData,
|
responseData: TResponseData,
|
||||||
languageCode: string = "en",
|
languageCode: string = "en",
|
||||||
finished: boolean = true,
|
|
||||||
questions?: TSurveyQuestion[] | undefined | null
|
questions?: TSurveyQuestion[] | undefined | null
|
||||||
): TValidationErrorMap | null => {
|
): TValidationErrorMap | null => {
|
||||||
// Use blocks if available, otherwise transform questions to blocks
|
// Use blocks if available, otherwise transform questions to blocks
|
||||||
@@ -42,11 +41,8 @@ export const validateResponseData = (
|
|||||||
// Extract elements from blocks
|
// Extract elements from blocks
|
||||||
const allElements = getElementsFromBlocks(blocksToUse);
|
const allElements = getElementsFromBlocks(blocksToUse);
|
||||||
|
|
||||||
// If response is not finished, only validate elements that are present in the response data
|
// Always validate only elements that are present in responseData
|
||||||
// This prevents "required" errors for fields the user hasn't reached yet
|
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
|
||||||
const elementsToValidate = finished
|
|
||||||
? allElements
|
|
||||||
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
|
|
||||||
|
|
||||||
// Validate selected elements
|
// Validate selected elements
|
||||||
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
|
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||||
|
|
||||||
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||||
@@ -51,7 +51,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
|||||||
return handleApiError(request, response.error as ApiErrorResponseV2);
|
return handleApiError(request, response.error as ApiErrorResponseV2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses.successResponse(response);
|
return responses.successResponse({
|
||||||
|
...response,
|
||||||
|
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,7 +201,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
|||||||
questionsResponse.data.blocks,
|
questionsResponse.data.blocks,
|
||||||
body.data,
|
body.data,
|
||||||
body.language ?? "en",
|
body.language ?? "en",
|
||||||
body.finished,
|
|
||||||
questionsResponse.data.questions
|
questionsResponse.data.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
|||||||
auditLog.newObject = response.data;
|
auditLog.newObject = response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses.successResponse(response);
|
return responses.successResponse({
|
||||||
|
...response,
|
||||||
|
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
action: "updated",
|
action: "updated",
|
||||||
targetType: "response",
|
targetType: "response",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
|
|||||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||||
|
|
||||||
export const GET = async (request: NextRequest) =>
|
export const GET = async (request: NextRequest) =>
|
||||||
@@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) =>
|
|||||||
|
|
||||||
environmentResponses.push(...res.data.data);
|
environmentResponses.push(...res.data.data);
|
||||||
|
|
||||||
return responses.successResponse({ data: environmentResponses });
|
return responses.successResponse({
|
||||||
|
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +136,6 @@ export const POST = async (request: Request) =>
|
|||||||
surveyQuestions.data.blocks,
|
surveyQuestions.data.blocks,
|
||||||
body.data,
|
body.data,
|
||||||
body.language ?? "en",
|
body.language ?? "en",
|
||||||
body.finished,
|
|
||||||
surveyQuestions.data.questions
|
surveyQuestions.data.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ export const rateLimitConfigs = {
|
|||||||
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
|
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
|
||||||
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
|
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
|
||||||
},
|
},
|
||||||
};
|
} as const;
|
||||||
|
|||||||
+18
-11
@@ -2,6 +2,7 @@ import { getServerSession } from "next-auth";
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
|
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getResponsesByContactId } from "@/lib/response/service";
|
import { getResponsesByContactId } from "@/lib/response/service";
|
||||||
import { getSurveys } from "@/lib/survey/service";
|
import { getSurveys } from "@/lib/survey/service";
|
||||||
@@ -10,27 +11,34 @@ import { findMatchingLocale } from "@/lib/utils/locale";
|
|||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { ResponseTimeline } from "./response-timeline";
|
import { ActivityTimeline } from "./activity-timeline";
|
||||||
|
|
||||||
interface ResponseSectionProps {
|
interface ActivitySectionProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
contactId: string;
|
contactId: string;
|
||||||
environmentTags: TTag[];
|
environmentTags: TTag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResponseSection = async ({ environment, contactId, environmentTags }: ResponseSectionProps) => {
|
export const ActivitySection = async ({ environment, contactId, environmentTags }: ActivitySectionProps) => {
|
||||||
const responses = await getResponsesByContactId(contactId);
|
const [responses, displays] = await Promise.all([
|
||||||
const surveyIds = responses?.map((response) => response.surveyId) || [];
|
getResponsesByContactId(contactId),
|
||||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : ((await getSurveys(environment.id)) ?? []);
|
getDisplaysByContactId(contactId),
|
||||||
const session = await getServerSession(authOptions);
|
]);
|
||||||
|
|
||||||
|
const allSurveyIds = [
|
||||||
|
...new Set([...(responses?.map((r) => r.surveyId) || []), ...displays.map((d) => d.surveyId)]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const surveys: TSurvey[] = allSurveyIds.length === 0 ? [] : ((await getSurveys(environment.id)) ?? []);
|
||||||
|
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
@@ -40,20 +48,19 @@ export const ResponseSection = async ({ environment, contactId, environmentTags
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponseTimeline
|
<ActivityTimeline
|
||||||
user={user}
|
user={user}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
responses={responses}
|
responses={responses}
|
||||||
|
displays={displays}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
environmentTags={environmentTags}
|
environmentTags={environmentTags}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDownUpIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TDisplay } from "@formbricks/types/displays";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { TTag } from "@formbricks/types/tags";
|
||||||
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { useMembershipRole } from "@/lib/membership/hooks/useMembershipRole";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||||
|
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||||
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
|
import { DisplayCard } from "./display-card";
|
||||||
|
import { ResponseSurveyCard } from "./response-survey-card";
|
||||||
|
|
||||||
|
type TTimelineItem =
|
||||||
|
| { type: "display"; data: Pick<TDisplay, "id" | "createdAt" | "surveyId"> }
|
||||||
|
| { type: "response"; data: TResponseWithQuotas };
|
||||||
|
|
||||||
|
interface ActivityTimelineProps {
|
||||||
|
surveys: TSurvey[];
|
||||||
|
user: TUser;
|
||||||
|
responses: TResponseWithQuotas[];
|
||||||
|
displays: Pick<TDisplay, "id" | "createdAt" | "surveyId">[];
|
||||||
|
environment: TEnvironment;
|
||||||
|
environmentTags: TTag[];
|
||||||
|
locale: TUserLocale;
|
||||||
|
projectPermission: TTeamPermission | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActivityTimeline = ({
|
||||||
|
surveys,
|
||||||
|
user,
|
||||||
|
responses: initialResponses,
|
||||||
|
displays,
|
||||||
|
environment,
|
||||||
|
environmentTags,
|
||||||
|
locale,
|
||||||
|
projectPermission,
|
||||||
|
}: ActivityTimelineProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [responses, setResponses] = useState(initialResponses);
|
||||||
|
const [isReversed, setIsReversed] = useState(false);
|
||||||
|
|
||||||
|
const { membershipRole } = useMembershipRole(environment.id, user.id);
|
||||||
|
|
||||||
|
const isReadOnly = useMemo(() => {
|
||||||
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
|
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||||
|
return isMember && hasReadAccess;
|
||||||
|
}, [membershipRole, projectPermission]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResponses(initialResponses);
|
||||||
|
}, [initialResponses]);
|
||||||
|
|
||||||
|
const updateResponseList = (responseIds: string[]) => {
|
||||||
|
setResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
|
||||||
|
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const timelineItems = useMemo(() => {
|
||||||
|
const displayItems: TTimelineItem[] = displays.map((d) => ({
|
||||||
|
type: "display" as const,
|
||||||
|
data: d,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const responseItems: TTimelineItem[] = responses.map((r) => ({
|
||||||
|
type: "response" as const,
|
||||||
|
data: r,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const merged = [...displayItems, ...responseItems].sort((a, b) => {
|
||||||
|
const aTime = new Date(a.data.createdAt).getTime();
|
||||||
|
const bTime = new Date(b.data.createdAt).getTime();
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
return isReversed ? [...merged].reverse() : merged;
|
||||||
|
}, [displays, responses, isReversed]);
|
||||||
|
|
||||||
|
const toggleSort = () => {
|
||||||
|
setIsReversed((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-3">
|
||||||
|
<div className="flex items-center justify-between pb-6">
|
||||||
|
<h2 className="text-lg font-bold text-slate-700">{t("common.activity")}</h2>
|
||||||
|
<div className="text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleSort}
|
||||||
|
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||||
|
<ArrowDownUpIcon className="inline h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{timelineItems.length === 0 ? (
|
||||||
|
<EmptyState text={t("environments.contacts.no_activity_yet")} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{timelineItems.map((item) =>
|
||||||
|
item.type === "display" ? (
|
||||||
|
<DisplayCard
|
||||||
|
key={`display-${item.data.id}`}
|
||||||
|
display={item.data}
|
||||||
|
surveys={surveys}
|
||||||
|
environmentId={environment.id}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ResponseSurveyCard
|
||||||
|
key={`response-${item.data.id}`}
|
||||||
|
response={item.data}
|
||||||
|
surveys={surveys}
|
||||||
|
user={user}
|
||||||
|
environmentTags={environmentTags}
|
||||||
|
environment={environment}
|
||||||
|
updateResponseList={updateResponseList}
|
||||||
|
updateResponse={updateResponse}
|
||||||
|
locale={locale}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||||
import { getResponsesByContactId } from "@/lib/response/service";
|
import { getResponsesByContactId } from "@/lib/response/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||||
@@ -17,8 +18,12 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
|||||||
throw new Error(t("environments.contacts.contact_not_found"));
|
throw new Error(t("environments.contacts.contact_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const responses = await getResponsesByContactId(contactId);
|
const [responses, displays] = await Promise.all([
|
||||||
|
getResponsesByContactId(contactId),
|
||||||
|
getDisplaysByContactId(contactId),
|
||||||
|
]);
|
||||||
const numberOfResponses = responses?.length || 0;
|
const numberOfResponses = responses?.length || 0;
|
||||||
|
const numberOfDisplays = displays?.length || 0;
|
||||||
|
|
||||||
const systemAttributes = attributesWithKeyInfo
|
const systemAttributes = attributesWithKeyInfo
|
||||||
.filter((attr) => attr.type === "default")
|
.filter((attr) => attr.type === "default")
|
||||||
@@ -85,6 +90,11 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
|||||||
<dt className="text-sm font-medium text-slate-500">{t("common.responses")}</dt>
|
<dt className="text-sm font-medium text-slate-500">{t("common.responses")}</dt>
|
||||||
<dd className="mt-1 text-sm text-slate-900">{numberOfResponses}</dd>
|
<dd className="mt-1 text-sm text-slate-900">{numberOfResponses}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-slate-500">{t("environments.contacts.displays")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-slate-900">{numberOfDisplays}</dd>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EyeIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TDisplay } from "@formbricks/types/displays";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
|
|
||||||
|
interface DisplayCardProps {
|
||||||
|
display: Pick<TDisplay, "id" | "createdAt" | "surveyId">;
|
||||||
|
surveys: TSurvey[];
|
||||||
|
environmentId: string;
|
||||||
|
locale: TUserLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisplayCard = ({ display, surveys, environmentId, locale }: DisplayCardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const survey = surveys.find((s) => s.id === display.surveyId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100">
|
||||||
|
<EyeIcon className="h-4 w-4 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">{t("environments.contacts.survey_viewed")}</p>
|
||||||
|
{survey ? (
|
||||||
|
<Link
|
||||||
|
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}
|
||||||
|
className="text-sm font-medium text-slate-700 hover:underline">
|
||||||
|
{survey.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium text-slate-500">{t("common.unknown_survey")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-500">{timeSince(display.createdAt.toString(), locale)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { TTag } from "@formbricks/types/tags";
|
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
|
||||||
import { useMembershipRole } from "@/lib/membership/hooks/useMembershipRole";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
|
||||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
|
||||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
|
||||||
|
|
||||||
interface ResponseTimelineProps {
|
|
||||||
surveys: TSurvey[];
|
|
||||||
user: TUser;
|
|
||||||
responses: TResponseWithQuotas[];
|
|
||||||
environment: TEnvironment;
|
|
||||||
environmentTags: TTag[];
|
|
||||||
locale: TUserLocale;
|
|
||||||
projectPermission: TTeamPermission | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResponseFeed = ({
|
|
||||||
responses,
|
|
||||||
environment,
|
|
||||||
surveys,
|
|
||||||
user,
|
|
||||||
environmentTags,
|
|
||||||
locale,
|
|
||||||
projectPermission,
|
|
||||||
}: ResponseTimelineProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [fetchedResponses, setFetchedResponses] = useState(responses);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFetchedResponses(responses);
|
|
||||||
}, [responses]);
|
|
||||||
|
|
||||||
const updateResponseList = (responseIds: string[]) => {
|
|
||||||
setFetchedResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
|
|
||||||
setFetchedResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{fetchedResponses.length === 0 ? (
|
|
||||||
<EmptyState text={t("environments.contacts.no_responses_found")} />
|
|
||||||
) : (
|
|
||||||
fetchedResponses.map((response) => (
|
|
||||||
<ResponseSurveyCard
|
|
||||||
key={response.id}
|
|
||||||
response={response}
|
|
||||||
surveys={surveys}
|
|
||||||
user={user}
|
|
||||||
environmentTags={environmentTags}
|
|
||||||
environment={environment}
|
|
||||||
updateResponseList={updateResponseList}
|
|
||||||
updateResponse={updateResponse}
|
|
||||||
locale={locale}
|
|
||||||
projectPermission={projectPermission}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ResponseSurveyCard = ({
|
|
||||||
response,
|
|
||||||
surveys,
|
|
||||||
user,
|
|
||||||
environmentTags,
|
|
||||||
environment,
|
|
||||||
updateResponseList,
|
|
||||||
updateResponse,
|
|
||||||
locale,
|
|
||||||
projectPermission,
|
|
||||||
}: {
|
|
||||||
response: TResponseWithQuotas;
|
|
||||||
surveys: TSurvey[];
|
|
||||||
user: TUser;
|
|
||||||
environmentTags: TTag[];
|
|
||||||
environment: TEnvironment;
|
|
||||||
updateResponseList: (responseIds: string[]) => void;
|
|
||||||
updateResponse: (responseId: string, response: TResponseWithQuotas) => void;
|
|
||||||
locale: TUserLocale;
|
|
||||||
projectPermission: TTeamPermission | null;
|
|
||||||
}) => {
|
|
||||||
const survey = surveys.find((survey) => {
|
|
||||||
return survey.id === response.surveyId;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { membershipRole } = useMembershipRole(survey?.environmentId || "", user.id);
|
|
||||||
const { isMember } = getAccessFlags(membershipRole);
|
|
||||||
|
|
||||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
|
||||||
|
|
||||||
const isReadOnly = isMember && hasReadAccess;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={response.id}>
|
|
||||||
{survey && (
|
|
||||||
<SingleResponseCard
|
|
||||||
response={response}
|
|
||||||
survey={replaceHeadlineRecall(survey, "default")}
|
|
||||||
user={user}
|
|
||||||
environmentTags={environmentTags}
|
|
||||||
environment={environment}
|
|
||||||
updateResponseList={updateResponseList}
|
|
||||||
updateResponse={updateResponse}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
locale={locale}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { TTag } from "@formbricks/types/tags";
|
||||||
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
|
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||||
|
|
||||||
|
interface ResponseSurveyCardProps {
|
||||||
|
response: TResponseWithQuotas;
|
||||||
|
surveys: TSurvey[];
|
||||||
|
user: TUser;
|
||||||
|
environmentTags: TTag[];
|
||||||
|
environment: TEnvironment;
|
||||||
|
updateResponseList: (responseIds: string[]) => void;
|
||||||
|
updateResponse: (responseId: string, response: TResponseWithQuotas) => void;
|
||||||
|
locale: TUserLocale;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResponseSurveyCard = ({
|
||||||
|
response,
|
||||||
|
surveys,
|
||||||
|
user,
|
||||||
|
environmentTags,
|
||||||
|
environment,
|
||||||
|
updateResponseList,
|
||||||
|
updateResponse,
|
||||||
|
locale,
|
||||||
|
isReadOnly,
|
||||||
|
}: ResponseSurveyCardProps) => {
|
||||||
|
const survey = surveys.find((s) => s.id === response.surveyId);
|
||||||
|
|
||||||
|
if (!survey) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SingleResponseCard
|
||||||
|
response={response}
|
||||||
|
survey={replaceHeadlineRecall(survey, "default")}
|
||||||
|
user={user}
|
||||||
|
environmentTags={environmentTags}
|
||||||
|
environment={environment}
|
||||||
|
updateResponseList={updateResponseList}
|
||||||
|
updateResponse={updateResponse}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowDownUpIcon } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { TTag } from "@formbricks/types/tags";
|
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
|
||||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
|
||||||
import { ResponseFeed } from "./response-feed";
|
|
||||||
|
|
||||||
interface ResponseTimelineProps {
|
|
||||||
surveys: TSurvey[];
|
|
||||||
user: TUser;
|
|
||||||
responses: TResponseWithQuotas[];
|
|
||||||
environment: TEnvironment;
|
|
||||||
environmentTags: TTag[];
|
|
||||||
locale: TUserLocale;
|
|
||||||
projectPermission: TTeamPermission | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResponseTimeline = ({
|
|
||||||
surveys,
|
|
||||||
user,
|
|
||||||
environment,
|
|
||||||
responses,
|
|
||||||
environmentTags,
|
|
||||||
locale,
|
|
||||||
projectPermission,
|
|
||||||
}: ResponseTimelineProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [sortedResponses, setSortedResponses] = useState(responses);
|
|
||||||
const toggleSortResponses = () => {
|
|
||||||
setSortedResponses([...sortedResponses].reverse());
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSortedResponses(responses);
|
|
||||||
}, [responses]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
// <div className="md:col-span-3">
|
|
||||||
<div className="col-span-3">
|
|
||||||
<div className="flex items-center justify-between pb-6">
|
|
||||||
<h2 className="text-lg font-bold text-slate-700">{t("common.responses")}</h2>
|
|
||||||
<div className="text-right">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleSortResponses}
|
|
||||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
|
||||||
<ArrowDownUpIcon className="inline h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ResponseFeed
|
|
||||||
responses={sortedResponses}
|
|
||||||
environment={environment}
|
|
||||||
surveys={surveys}
|
|
||||||
user={user}
|
|
||||||
environmentTags={environmentTags}
|
|
||||||
locale={locale}
|
|
||||||
projectPermission={projectPermission}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -11,7 +11,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
import { ResponseSection } from "./components/response-section";
|
import { ActivitySection } from "./components/activity-section";
|
||||||
|
|
||||||
export const SingleContactPage = async (props: {
|
export const SingleContactPage = async (props: {
|
||||||
params: Promise<{ environmentId: string; contactId: string }>;
|
params: Promise<{ environmentId: string; contactId: string }>;
|
||||||
@@ -64,7 +64,7 @@ export const SingleContactPage = async (props: {
|
|||||||
<section className="pb-24 pt-6">
|
<section className="pb-24 pt-6">
|
||||||
<div className="grid grid-cols-4 gap-x-8">
|
<div className="grid grid-cols-4 gap-x-8">
|
||||||
<AttributesSection contactId={params.contactId} />
|
<AttributesSection contactId={params.contactId} />
|
||||||
<ResponseSection
|
<ActivitySection
|
||||||
environment={environment}
|
environment={environment}
|
||||||
contactId={params.contactId}
|
contactId={params.contactId}
|
||||||
environmentTags={environmentTags}
|
environmentTags={environmentTags}
|
||||||
|
|||||||
+75
-26
@@ -7,10 +7,9 @@ import { validateInputs } from "@/lib/utils/validate";
|
|||||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||||
import { getPersonSegmentIds, getSegments } from "./segments";
|
import { getPersonSegmentIds, getSegments } from "./segments";
|
||||||
|
|
||||||
// Mock the cache functions
|
|
||||||
vi.mock("@/lib/cache", () => ({
|
vi.mock("@/lib/cache", () => ({
|
||||||
cache: {
|
cache: {
|
||||||
withCache: vi.fn(async (fn) => await fn()), // Just execute the function without caching for tests
|
withCache: vi.fn(async (fn) => await fn()),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -30,15 +29,15 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
contact: {
|
contact: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
|
$transaction: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock React cache
|
|
||||||
vi.mock("react", async () => {
|
vi.mock("react", async () => {
|
||||||
const actual = await vi.importActual("react");
|
const actual = await vi.importActual("react");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
cache: <T extends (...args: any[]) => any>(fn: T): T => fn, // Return the function with the same type signature
|
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,22 +96,20 @@ describe("segments lib", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getPersonSegmentIds", () => {
|
describe("getPersonSegmentIds", () => {
|
||||||
|
const mockWhereClause = { AND: [{ environmentId: mockEnvironmentId }, {}] };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
||||||
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
||||||
);
|
);
|
||||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: { whereClause: { AND: [{ environmentId: mockEnvironmentId }, {}] } },
|
data: { whereClause: mockWhereClause },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return person segment IDs successfully", async () => {
|
test("should return person segment IDs successfully", async () => {
|
||||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
|
||||||
typeof prisma.contact,
|
|
||||||
unknown,
|
|
||||||
"findFirst"
|
|
||||||
>);
|
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -128,12 +125,12 @@ describe("segments lib", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||||
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(mockSegmentsData.length);
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
|
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return empty array if no segments exist", async () => {
|
test("should return empty array if no segments exist", async () => {
|
||||||
vi.mocked(prisma.segment.findMany).mockResolvedValue([]); // No segments
|
vi.mocked(prisma.segment.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -144,10 +141,11 @@ describe("segments lib", () => {
|
|||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
|
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return empty array if segments exist but none match", async () => {
|
test("should return empty array if segments exist but none match", async () => {
|
||||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
vi.mocked(prisma.$transaction).mockResolvedValue([null, null]);
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -155,16 +153,14 @@ describe("segments lib", () => {
|
|||||||
mockContactUserId,
|
mockContactUserId,
|
||||||
mockDeviceType
|
mockDeviceType
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should call validateInputs with correct parameters", async () => {
|
test("should call validateInputs with correct parameters", async () => {
|
||||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
|
||||||
typeof prisma.contact,
|
|
||||||
unknown,
|
|
||||||
"findFirst"
|
|
||||||
>);
|
|
||||||
|
|
||||||
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
|
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
|
||||||
expect(validateInputs).toHaveBeenCalledWith(
|
expect(validateInputs).toHaveBeenCalledWith(
|
||||||
@@ -175,14 +171,7 @@ describe("segments lib", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should return only matching segment IDs", async () => {
|
test("should return only matching segment IDs", async () => {
|
||||||
// First segment matches, second doesn't
|
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, null]);
|
||||||
vi.mocked(prisma.contact.findFirst)
|
|
||||||
.mockResolvedValueOnce({ id: mockContactId } as Prisma.Result<
|
|
||||||
typeof prisma.contact,
|
|
||||||
unknown,
|
|
||||||
"findFirst"
|
|
||||||
>) // First segment matches
|
|
||||||
.mockResolvedValueOnce(null); // Second segment does not match
|
|
||||||
|
|
||||||
const result = await getPersonSegmentIds(
|
const result = await getPersonSegmentIds(
|
||||||
mockEnvironmentId,
|
mockEnvironmentId,
|
||||||
@@ -193,6 +182,66 @@ describe("segments lib", () => {
|
|||||||
|
|
||||||
expect(result).toEqual([mockSegmentsData[0].id]);
|
expect(result).toEqual([mockSegmentsData[0].id]);
|
||||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include segments with no filters as always-matching", async () => {
|
||||||
|
const segmentsWithEmptyFilters = [
|
||||||
|
{ id: "segment-no-filter", filters: [] },
|
||||||
|
{ id: "segment-with-filter", filters: [{}] as TBaseFilter[] },
|
||||||
|
];
|
||||||
|
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
||||||
|
segmentsWithEmptyFilters as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
|
||||||
|
|
||||||
|
const result = await getPersonSegmentIds(
|
||||||
|
mockEnvironmentId,
|
||||||
|
mockContactId,
|
||||||
|
mockContactUserId,
|
||||||
|
mockDeviceType
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain("segment-no-filter");
|
||||||
|
expect(result).toContain("segment-with-filter");
|
||||||
|
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip segments where filter query building fails", async () => {
|
||||||
|
vi.mocked(segmentFilterToPrismaQuery)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { whereClause: mockWhereClause },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: { type: "bad_request", message: "Invalid filters", details: [] },
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
|
||||||
|
|
||||||
|
const result = await getPersonSegmentIds(
|
||||||
|
mockEnvironmentId,
|
||||||
|
mockContactId,
|
||||||
|
mockContactUserId,
|
||||||
|
mockDeviceType
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(["segment1"]);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array on unexpected error", async () => {
|
||||||
|
vi.mocked(prisma.segment.findMany).mockRejectedValue(new Error("Unexpected"));
|
||||||
|
|
||||||
|
const result = await getPersonSegmentIds(
|
||||||
|
mockEnvironmentId,
|
||||||
|
mockContactId,
|
||||||
|
mockContactUserId,
|
||||||
|
mockDeviceType
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,47 +37,6 @@ export const getSegments = reactCache(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a contact matches a segment using Prisma query
|
|
||||||
* This leverages native DB types (valueDate, valueNumber) for accurate comparisons
|
|
||||||
* Device filters are evaluated at query build time using the provided deviceType
|
|
||||||
*/
|
|
||||||
const isContactInSegment = async (
|
|
||||||
contactId: string,
|
|
||||||
segmentId: string,
|
|
||||||
filters: TBaseFilters,
|
|
||||||
environmentId: string,
|
|
||||||
deviceType: "phone" | "desktop"
|
|
||||||
): Promise<boolean> => {
|
|
||||||
// If no filters, segment matches all contacts
|
|
||||||
if (!filters || filters.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryResult = await segmentFilterToPrismaQuery(segmentId, filters, environmentId, deviceType);
|
|
||||||
|
|
||||||
if (!queryResult.ok) {
|
|
||||||
logger.warn(
|
|
||||||
{ segmentId, environmentId, error: queryResult.error },
|
|
||||||
"Failed to build Prisma query for segment"
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { whereClause } = queryResult.data;
|
|
||||||
|
|
||||||
// Check if this specific contact matches the segment filters
|
|
||||||
const matchingContact = await prisma.contact.findFirst({
|
|
||||||
where: {
|
|
||||||
id: contactId,
|
|
||||||
...whereClause,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return matchingContact !== null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPersonSegmentIds = async (
|
export const getPersonSegmentIds = async (
|
||||||
environmentId: string,
|
environmentId: string,
|
||||||
contactId: string,
|
contactId: string,
|
||||||
@@ -89,23 +48,70 @@ export const getPersonSegmentIds = async (
|
|||||||
|
|
||||||
const segments = await getSegments(environmentId);
|
const segments = await getSegments(environmentId);
|
||||||
|
|
||||||
// fast path; if there are no segments, return an empty array
|
if (!segments || !Array.isArray(segments) || segments.length === 0) {
|
||||||
if (!segments || !Array.isArray(segments)) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device filters are evaluated at query build time using the provided deviceType
|
// Phase 1: Build all Prisma where clauses concurrently.
|
||||||
const segmentPromises = segments.map(async (segment) => {
|
// This converts segment filters into where clauses without per-contact DB queries.
|
||||||
const filters = segment.filters;
|
const segmentWithClauses = await Promise.all(
|
||||||
const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType);
|
segments.map(async (segment) => {
|
||||||
return isIncluded ? segment.id : null;
|
const filters = segment.filters as TBaseFilters | null;
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(segmentPromises);
|
if (!filters || filters.length === 0) {
|
||||||
|
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
|
||||||
|
}
|
||||||
|
|
||||||
return results.filter((id): id is string => id !== null);
|
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||||
|
|
||||||
|
if (!queryResult.ok) {
|
||||||
|
logger.warn(
|
||||||
|
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||||
|
"Failed to build Prisma query for segment"
|
||||||
|
);
|
||||||
|
return { segmentId: segment.id, whereClause: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
|
||||||
|
const alwaysMatchIds: string[] = [];
|
||||||
|
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||||
|
|
||||||
|
for (const item of segmentWithClauses) {
|
||||||
|
if (item.whereClause === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(item.whereClause).length === 0) {
|
||||||
|
alwaysMatchIds.push(item.segmentId);
|
||||||
|
} else {
|
||||||
|
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toCheck.length === 0) {
|
||||||
|
return alwaysMatchIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Batch all contact-match checks into a single DB transaction.
|
||||||
|
// Replaces N individual findFirst queries with one batched round-trip.
|
||||||
|
const batchResults = await prisma.$transaction(
|
||||||
|
toCheck.map(({ whereClause }) =>
|
||||||
|
prisma.contact.findFirst({
|
||||||
|
where: { id: contactId, ...whereClause },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase 3: Collect matching segment IDs
|
||||||
|
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||||
|
|
||||||
|
return [...alwaysMatchIds, ...dbMatchIds];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error for debugging but don't throw to prevent "segments is not iterable" error
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
environmentId,
|
environmentId,
|
||||||
|
|||||||
@@ -2,13 +2,26 @@ import { NextRequest, userAgent } from "next/server";
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
|
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||||
import { TJsPersonState } from "@formbricks/types/js";
|
import { TJsPersonState } from "@formbricks/types/js";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { updateUser } from "./lib/update-user";
|
import { updateUser } from "./lib/update-user";
|
||||||
|
|
||||||
|
const handleError = (err: unknown, url: string): { response: Response } => {
|
||||||
|
if (err instanceof ResourceNotFoundError) {
|
||||||
|
return { response: responses.notFoundResponse(err.resourceType, err.resourceId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof ValidationError) {
|
||||||
|
return { response: responses.badRequestResponse(err.message, undefined, true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error({ error: err, url }, "Error in POST /api/v1/client/[environmentId]/user");
|
||||||
|
return { response: responses.internalServerErrorResponse("Unable to fetch user state", true) };
|
||||||
|
};
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
export const OPTIONS = async (): Promise<Response> => {
|
||||||
return responses.successResponse(
|
return responses.successResponse(
|
||||||
{},
|
{},
|
||||||
@@ -123,16 +136,7 @@ export const POST = withV1ApiWrapper({
|
|||||||
response: responses.successResponse(responseJson, true),
|
response: responses.successResponse(responseJson, true),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ResourceNotFoundError) {
|
return handleError(err, req.url);
|
||||||
return {
|
|
||||||
response: responses.notFoundResponse(err.resourceType, err.resourceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/user");
|
|
||||||
return {
|
|
||||||
response: responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
|||||||
disabled={!formData.key || !formData.name || !!keyError}
|
disabled={!formData.key || !formData.name || !!keyError}
|
||||||
loading={isCreating}
|
loading={isCreating}
|
||||||
type="submit">
|
type="submit">
|
||||||
{t("environments.contacts.create_key")}
|
{t("environments.contacts.create_attribute")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getLocale } from "@/lingodotdev/language";
|
import { getLocale } from "@/lingodotdev/language";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
|
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
|
||||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -13,7 +14,7 @@ export const AttributesPage = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const params = await paramsProps;
|
const params = await paramsProps;
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
|
const t = await getTranslate();
|
||||||
const [{ isReadOnly }, contactAttributeKeys] = await Promise.all([
|
const [{ isReadOnly }, contactAttributeKeys] = await Promise.all([
|
||||||
getEnvironmentAuth(params.environmentId),
|
getEnvironmentAuth(params.environmentId),
|
||||||
getContactAttributeKeys(params.environmentId),
|
getContactAttributeKeys(params.environmentId),
|
||||||
@@ -23,7 +24,7 @@ export const AttributesPage = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ContactsPageLayout
|
<ContactsPageLayout
|
||||||
pageTitle="Contacts"
|
pageTitle={t("common.contacts")}
|
||||||
activeId="attributes"
|
activeId="attributes"
|
||||||
environmentId={params.environmentId}
|
environmentId={params.environmentId}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||||
@@ -10,12 +11,13 @@ import { TContactTableData } from "../types/contact";
|
|||||||
export const generateContactTableColumns = (
|
export const generateContactTableColumns = (
|
||||||
searchValue: string,
|
searchValue: string,
|
||||||
data: TContactTableData[],
|
data: TContactTableData[],
|
||||||
isReadOnly: boolean
|
isReadOnly: boolean,
|
||||||
|
t: TFunction
|
||||||
): ColumnDef<TContactTableData>[] => {
|
): ColumnDef<TContactTableData>[] => {
|
||||||
const userColumn: ColumnDef<TContactTableData> = {
|
const userColumn: ColumnDef<TContactTableData> = {
|
||||||
id: "contactsTableUser",
|
id: "contactsTableUser",
|
||||||
accessorKey: "contactsTableUser",
|
accessorKey: "contactsTableUser",
|
||||||
header: "ID",
|
header: t("common.id"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const contactId = row.original.id;
|
const contactId = row.original.id;
|
||||||
return <HighlightedText value={contactId} searchValue={searchValue} />;
|
return <HighlightedText value={contactId} searchValue={searchValue} />;
|
||||||
@@ -25,7 +27,7 @@ export const generateContactTableColumns = (
|
|||||||
const userIdColumn: ColumnDef<TContactTableData> = {
|
const userIdColumn: ColumnDef<TContactTableData> = {
|
||||||
id: "userId",
|
id: "userId",
|
||||||
accessorKey: "userId",
|
accessorKey: "userId",
|
||||||
header: "User ID",
|
header: t("common.user_id"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const userId = row.original.userId;
|
const userId = row.original.userId;
|
||||||
return <IdBadge id={userId} />;
|
return <IdBadge id={userId} />;
|
||||||
@@ -35,7 +37,7 @@ export const generateContactTableColumns = (
|
|||||||
const emailColumn: ColumnDef<TContactTableData> = {
|
const emailColumn: ColumnDef<TContactTableData> = {
|
||||||
id: "email",
|
id: "email",
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
header: "Email",
|
header: t("common.email"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const email = row.original.email;
|
const email = row.original.email;
|
||||||
if (email) {
|
if (email) {
|
||||||
@@ -47,7 +49,7 @@ export const generateContactTableColumns = (
|
|||||||
const firstNameColumn: ColumnDef<TContactTableData> = {
|
const firstNameColumn: ColumnDef<TContactTableData> = {
|
||||||
id: "firstName",
|
id: "firstName",
|
||||||
accessorKey: "firstName",
|
accessorKey: "firstName",
|
||||||
header: "First Name",
|
header: t("common.first_name"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const firstName = row.original.firstName;
|
const firstName = row.original.firstName;
|
||||||
return <HighlightedText value={firstName} searchValue={searchValue} />;
|
return <HighlightedText value={firstName} searchValue={searchValue} />;
|
||||||
@@ -57,7 +59,7 @@ export const generateContactTableColumns = (
|
|||||||
const lastNameColumn: ColumnDef<TContactTableData> = {
|
const lastNameColumn: ColumnDef<TContactTableData> = {
|
||||||
id: "lastName",
|
id: "lastName",
|
||||||
accessorKey: "lastName",
|
accessorKey: "lastName",
|
||||||
header: "Last Name",
|
header: t("common.last_name"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const lastName = row.original.lastName;
|
const lastName = row.original.lastName;
|
||||||
return <HighlightedText value={lastName} searchValue={searchValue} />;
|
return <HighlightedText value={lastName} searchValue={searchValue} />;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const ContactsTable = ({
|
|||||||
|
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return generateContactTableColumns(searchValue, data, isReadOnly);
|
return generateContactTableColumns(searchValue, data, isReadOnly, t);
|
||||||
}, [searchValue, data, isReadOnly]);
|
}, [searchValue, data, isReadOnly]);
|
||||||
|
|
||||||
// Load saved settings from localStorage
|
// Load saved settings from localStorage
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export const prepareNewSDKAttributeForStorage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
|
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
|
||||||
// String type - only use value column
|
|
||||||
let stringValue: string;
|
let stringValue: string;
|
||||||
|
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
|
|||||||
@@ -437,4 +437,22 @@ describe("updateAttributes", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
|
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coerces boolean attribute values to strings", async () => {
|
||||||
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||||
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||||
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||||
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
const attributes = { name: true, email: "john@example.com" };
|
||||||
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalled();
|
||||||
|
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
||||||
|
// Both name (coerced from boolean) and email should be upserted
|
||||||
|
expect(transactionCall).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,7 +130,12 @@ export const updateAttributes = async (
|
|||||||
const messages: TAttributeUpdateMessage[] = [];
|
const messages: TAttributeUpdateMessage[] = [];
|
||||||
const errors: TAttributeUpdateMessage[] = [];
|
const errors: TAttributeUpdateMessage[] = [];
|
||||||
|
|
||||||
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
|
// Coerce boolean values to strings (SDK may send booleans for string attributes)
|
||||||
|
const coercedAttributes: Record<string, string | number> = {};
|
||||||
|
for (const [key, value] of Object.entries(contactAttributesParam)) {
|
||||||
|
coercedAttributes[key] = typeof value === "boolean" ? String(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
const emailValue =
|
const emailValue =
|
||||||
contactAttributesParam.email === null || contactAttributesParam.email === undefined
|
contactAttributesParam.email === null || contactAttributesParam.email === undefined
|
||||||
? null
|
? null
|
||||||
@@ -154,7 +159,7 @@ export const updateAttributes = async (
|
|||||||
const userIdExists = !!existingUserIdAttribute;
|
const userIdExists = !!existingUserIdAttribute;
|
||||||
|
|
||||||
// Remove email and/or userId from attributes if they already exist on another contact
|
// Remove email and/or userId from attributes if they already exist on another contact
|
||||||
let contactAttributes = { ...contactAttributesParam };
|
let contactAttributes = { ...coercedAttributes };
|
||||||
|
|
||||||
// Determine what the final email and userId values will be after this update
|
// Determine what the final email and userId values will be after this update
|
||||||
// Only consider a value as "submitted" if it was explicitly included in the attributes
|
// Only consider a value as "submitted" if it was explicitly included in the attributes
|
||||||
|
|||||||
@@ -13,22 +13,14 @@ describe("validateAndParseAttributeValue", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("converts numbers to string", () => {
|
test("rejects number values (SDK must pass actual strings)", () => {
|
||||||
const result = validateAndParseAttributeValue(42, "string", "testKey");
|
const result = validateAndParseAttributeValue(42, "string", "testKey");
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(false);
|
||||||
if (result.valid) {
|
if (!result.valid) {
|
||||||
expect(result.parsedValue.value).toBe("42");
|
expect(result.error.code).toBe("string_type_mismatch");
|
||||||
expect(result.parsedValue.valueNumber).toBeNull();
|
expect(result.error.params.key).toBe("testKey");
|
||||||
}
|
expect(result.error.params.type).toBe("number");
|
||||||
});
|
expect(formatValidationError(result.error)).toContain("received a number");
|
||||||
|
|
||||||
test("converts Date to ISO string", () => {
|
|
||||||
const date = new Date("2024-01-15T10:30:00.000Z");
|
|
||||||
const result = validateAndParseAttributeValue(date, "string", "testKey");
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
if (result.valid) {
|
|
||||||
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
|
|
||||||
expect(result.parsedValue.valueDate).toBeNull();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,15 +27,6 @@ export type TAttributeValidationResult =
|
|||||||
error: TAttributeValidationError;
|
error: TAttributeValidationError;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts any value to a string representation
|
|
||||||
*/
|
|
||||||
const convertToString = (value: TRawValue): string => {
|
|
||||||
if (value instanceof Date) return value.toISOString();
|
|
||||||
if (typeof value === "number") return String(value);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a human-readable type name for error messages
|
* Gets a human-readable type name for error messages
|
||||||
*/
|
*/
|
||||||
@@ -45,16 +36,28 @@ const getTypeName = (value: TRawValue): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and parses a string type attribute
|
* Validates and parses a string type attribute.
|
||||||
*/
|
*/
|
||||||
const validateStringType = (value: TRawValue): TAttributeValidationResult => ({
|
const validateStringType = (value: TRawValue, attributeKey: string): TAttributeValidationResult => {
|
||||||
valid: true,
|
if (typeof value === "string") {
|
||||||
parsedValue: {
|
return {
|
||||||
value: convertToString(value),
|
valid: true,
|
||||||
valueNumber: null,
|
parsedValue: {
|
||||||
valueDate: null,
|
value,
|
||||||
},
|
valueNumber: null,
|
||||||
});
|
valueDate: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: {
|
||||||
|
code: "string_type_mismatch",
|
||||||
|
params: { key: attributeKey, type: getTypeName(value) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and parses a number type attribute.
|
* Validates and parses a number type attribute.
|
||||||
@@ -170,13 +173,13 @@ export const validateAndParseAttributeValue = (
|
|||||||
): TAttributeValidationResult => {
|
): TAttributeValidationResult => {
|
||||||
switch (expectedDataType) {
|
switch (expectedDataType) {
|
||||||
case "string":
|
case "string":
|
||||||
return validateStringType(value);
|
return validateStringType(value, attributeKey);
|
||||||
case "number":
|
case "number":
|
||||||
return validateNumberType(value, attributeKey);
|
return validateNumberType(value, attributeKey);
|
||||||
case "date":
|
case "date":
|
||||||
return validateDateType(value, attributeKey);
|
return validateDateType(value, attributeKey);
|
||||||
default:
|
default:
|
||||||
return validateStringType(value);
|
return validateStringType(value, attributeKey);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,6 +188,8 @@ export const validateAndParseAttributeValue = (
|
|||||||
* Used for API/SDK responses.
|
* Used for API/SDK responses.
|
||||||
*/
|
*/
|
||||||
const VALIDATION_ERROR_TEMPLATES: Record<string, string> = {
|
const VALIDATION_ERROR_TEMPLATES: Record<string, string> = {
|
||||||
|
string_type_mismatch:
|
||||||
|
"Attribute '{key}' expects a string but received a {type}. Pass an actual string value.",
|
||||||
number_type_mismatch:
|
number_type_mismatch:
|
||||||
"Attribute '{key}' expects a number but received a string. Pass an actual number value (e.g., 123 instead of \"123\").",
|
"Attribute '{key}' expects a number but received a string. Pass an actual number value (e.g., 123 instead of \"123\").",
|
||||||
date_invalid: "Attribute '{key}' expects a valid date. Received: Invalid Date",
|
date_invalid: "Attribute '{key}' expects a valid date. Received: Invalid Date",
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
import { UsersIcon } from "lucide-react";
|
import { UsersIcon } from "lucide-react";
|
||||||
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||||
|
|
||||||
export const generateSegmentTableColumns = (): ColumnDef<TSegmentWithSurveyNames>[] => {
|
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
|
||||||
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
||||||
id: "title",
|
id: "title",
|
||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "Title",
|
header: t("common.title"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -30,7 +31,7 @@ export const generateSegmentTableColumns = (): ColumnDef<TSegmentWithSurveyNames
|
|||||||
const updatedAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
const updatedAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
||||||
id: "updatedAt",
|
id: "updatedAt",
|
||||||
accessorKey: "updatedAt",
|
accessorKey: "updatedAt",
|
||||||
header: "Updated",
|
header: t("common.updated_at"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-sm text-slate-900">
|
<span className="text-sm text-slate-900">
|
||||||
@@ -43,7 +44,7 @@ export const generateSegmentTableColumns = (): ColumnDef<TSegmentWithSurveyNames
|
|||||||
const createdAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
const createdAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
|
||||||
id: "createdAt",
|
id: "createdAt",
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: "Created",
|
header: t("common.created_at"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function SegmentTable({
|
|||||||
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
|
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return generateSegmentTableColumns();
|
return generateSegmentTableColumns(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { getSegment } from "../segments";
|
|||||||
import { segmentFilterToPrismaQuery } from "./prisma-query";
|
import { segmentFilterToPrismaQuery } from "./prisma-query";
|
||||||
|
|
||||||
const mockQueryRawUnsafe = vi.fn();
|
const mockQueryRawUnsafe = vi.fn();
|
||||||
|
const mockFindFirst = vi.fn();
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
$queryRawUnsafe: (...args: unknown[]) => mockQueryRawUnsafe(...args),
|
$queryRawUnsafe: (...args: unknown[]) => mockQueryRawUnsafe(...args),
|
||||||
|
contactAttribute: {
|
||||||
|
findFirst: (...args: unknown[]) => mockFindFirst(...args),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -26,7 +30,9 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Default mock: number filter raw SQL returns one matching contact
|
// Default: backfill is complete, no un-migrated rows
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
// Fallback path mock: raw SQL returns one matching contact when un-migrated rows exist
|
||||||
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
|
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +151,16 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
OR: [{ id: { in: ["mock-contact-1"] } }],
|
OR: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "age" },
|
||||||
|
valueNumber: { gt: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -757,7 +772,12 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(subgroup.AND[0].AND[2]).toStrictEqual({
|
expect(subgroup.AND[0].AND[2]).toStrictEqual({
|
||||||
id: { in: ["mock-contact-1"] },
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "age" },
|
||||||
|
valueNumber: { gte: 18 },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Segment inclusion
|
// Segment inclusion
|
||||||
@@ -1158,10 +1178,23 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second subgroup (numeric operators - now use raw SQL subquery returning contact IDs)
|
// Second subgroup (numeric operators - uses clean Prisma filter post-backfill)
|
||||||
const secondSubgroup = whereClause.AND?.[0];
|
const secondSubgroup = whereClause.AND?.[0];
|
||||||
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
||||||
id: { in: ["mock-contact-1"] },
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "loginCount" },
|
||||||
|
valueNumber: { gt: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "purchaseAmount" },
|
||||||
|
valueNumber: { lte: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Third subgroup (negation operators in OR clause)
|
// Third subgroup (negation operators in OR clause)
|
||||||
@@ -1196,6 +1229,104 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("number filter falls back to raw SQL when un-migrated rows exist", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue({ id: "unmigrated-row-1" });
|
||||||
|
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
|
||||||
|
|
||||||
|
const filters: TBaseFilters = [
|
||||||
|
{
|
||||||
|
id: "filter_1",
|
||||||
|
connector: null,
|
||||||
|
resource: {
|
||||||
|
id: "attr_1",
|
||||||
|
root: {
|
||||||
|
type: "attribute" as const,
|
||||||
|
contactAttributeKey: "age",
|
||||||
|
},
|
||||||
|
value: 25,
|
||||||
|
qualifier: {
|
||||||
|
operator: "greaterThan",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
const filterClause = result.data.whereClause.AND?.[1] as any;
|
||||||
|
expect(filterClause.AND[0]).toEqual({
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "age" },
|
||||||
|
valueNumber: { gt: 25 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: { in: ["mock-contact-1"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockFindFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
attributeKey: {
|
||||||
|
key: "age",
|
||||||
|
environmentId: mockEnvironmentId,
|
||||||
|
dataType: "number",
|
||||||
|
},
|
||||||
|
valueNumber: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockQueryRawUnsafe).toHaveBeenCalled();
|
||||||
|
const sqlCall = mockQueryRawUnsafe.mock.calls[0];
|
||||||
|
expect(sqlCall[0]).toContain('cak."environmentId" = $4');
|
||||||
|
expect(sqlCall[4]).toBe(mockEnvironmentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("number filter uses clean Prisma query when backfill is complete", async () => {
|
||||||
|
const filters: TBaseFilters = [
|
||||||
|
{
|
||||||
|
id: "filter_1",
|
||||||
|
connector: null,
|
||||||
|
resource: {
|
||||||
|
id: "attr_1",
|
||||||
|
root: {
|
||||||
|
type: "attribute" as const,
|
||||||
|
contactAttributeKey: "score",
|
||||||
|
},
|
||||||
|
value: 100,
|
||||||
|
qualifier: {
|
||||||
|
operator: "lessEqual",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
const filterClause = result.data.whereClause.AND?.[1] as any;
|
||||||
|
expect(filterClause.AND[0]).toEqual({
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "score" },
|
||||||
|
valueNumber: { lte: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockFindFirst).toHaveBeenCalled();
|
||||||
|
expect(mockQueryRawUnsafe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// DATE FILTER TESTS
|
// DATE FILTER TESTS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -1232,7 +1363,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
{
|
{
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: "purchaseDate" },
|
attributeKey: { key: "purchaseDate", dataType: "date" },
|
||||||
OR: [
|
OR: [
|
||||||
{ valueDate: { lt: new Date(targetDate) } },
|
{ valueDate: { lt: new Date(targetDate) } },
|
||||||
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
|
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
|
||||||
@@ -1276,7 +1407,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
{
|
{
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: "signupDate" },
|
attributeKey: { key: "signupDate", dataType: "date" },
|
||||||
OR: [
|
OR: [
|
||||||
{ valueDate: { gt: new Date(targetDate) } },
|
{ valueDate: { gt: new Date(targetDate) } },
|
||||||
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
|
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
|
||||||
@@ -1321,7 +1452,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
{
|
{
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: "lastActivityDate" },
|
attributeKey: { key: "lastActivityDate", dataType: "date" },
|
||||||
OR: [
|
OR: [
|
||||||
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
|
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
|
||||||
{
|
{
|
||||||
@@ -1638,8 +1769,15 @@ describe("segmentFilterToPrismaQuery", () => {
|
|||||||
mode: "insensitive",
|
mode: "insensitive",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Number filter uses raw SQL subquery (transition code) returning contact IDs
|
// Number filter uses clean Prisma filter post-backfill
|
||||||
expect(andConditions[1]).toEqual({ id: { in: ["mock-contact-1"] } });
|
expect(andConditions[1]).toEqual({
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: "purchaseCount" },
|
||||||
|
valueNumber: { gt: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Date filter uses OR fallback with 'valueDate' and string 'value'
|
// Date filter uses OR fallback with 'valueDate' and string 'value'
|
||||||
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");
|
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
|
|||||||
return {
|
return {
|
||||||
attributes: {
|
attributes: {
|
||||||
some: {
|
some: {
|
||||||
attributeKey: { key: contactAttributeKey },
|
attributeKey: { key: contactAttributeKey, dataType: "date" },
|
||||||
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
|
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -116,59 +116,102 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Prisma where clause for number attribute filters.
|
* Builds a Prisma where clause for number attribute filters.
|
||||||
* Uses a raw SQL subquery to handle both migrated rows (valueNumber populated)
|
* Uses a clean Prisma query when all rows have valueNumber populated (post-backfill).
|
||||||
* and un-migrated rows (valueNumber NULL, value contains numeric string).
|
* Falls back to a raw SQL subquery for un-migrated rows (valueNumber NULL, value contains numeric string).
|
||||||
* This is transition code for the deferred value backfill.
|
|
||||||
*
|
*
|
||||||
* TODO: After the backfill script has been run and all valueNumber columns are populated,
|
* TODO: After the backfill script has been run and all valueNumber columns are populated,
|
||||||
* revert this to the clean Prisma-only version that queries valueNumber directly.
|
* remove the un-migrated fallback path entirely.
|
||||||
*/
|
*/
|
||||||
const buildNumberAttributeFilterWhereClause = async (
|
const buildNumberAttributeFilterWhereClause = async (
|
||||||
filter: TSegmentAttributeFilter
|
filter: TSegmentAttributeFilter,
|
||||||
|
environmentId: string
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root, qualifier, value } = filter;
|
const { root, qualifier, value } = filter;
|
||||||
const { contactAttributeKey } = root;
|
const { contactAttributeKey } = root;
|
||||||
const { operator } = qualifier;
|
const { operator } = qualifier;
|
||||||
|
|
||||||
const numericValue = typeof value === "number" ? value : Number(value);
|
const numericValue = typeof value === "number" ? value : Number(value);
|
||||||
const sqlOp = SQL_OPERATORS[operator];
|
|
||||||
|
|
||||||
if (!sqlOp) {
|
let valueNumberCondition: Prisma.FloatNullableFilter;
|
||||||
return {};
|
|
||||||
|
switch (operator) {
|
||||||
|
case "greaterThan":
|
||||||
|
valueNumberCondition = { gt: numericValue };
|
||||||
|
break;
|
||||||
|
case "greaterEqual":
|
||||||
|
valueNumberCondition = { gte: numericValue };
|
||||||
|
break;
|
||||||
|
case "lessThan":
|
||||||
|
valueNumberCondition = { lt: numericValue };
|
||||||
|
break;
|
||||||
|
case "lessEqual":
|
||||||
|
valueNumberCondition = { lte: numericValue };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingContactIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
|
const migratedFilter: Prisma.ContactWhereInput = {
|
||||||
|
attributes: {
|
||||||
|
some: {
|
||||||
|
attributeKey: { key: contactAttributeKey },
|
||||||
|
valueNumber: valueNumberCondition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUnmigratedRows = await prisma.contactAttribute.findFirst({
|
||||||
|
where: {
|
||||||
|
attributeKey: {
|
||||||
|
key: contactAttributeKey,
|
||||||
|
environmentId,
|
||||||
|
dataType: "number",
|
||||||
|
},
|
||||||
|
valueNumber: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasUnmigratedRows) {
|
||||||
|
return migratedFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlOp = SQL_OPERATORS[operator];
|
||||||
|
const unmigratedMatchingIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
|
||||||
`
|
`
|
||||||
SELECT DISTINCT ca."contactId"
|
SELECT DISTINCT ca."contactId"
|
||||||
FROM "ContactAttribute" ca
|
FROM "ContactAttribute" ca
|
||||||
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
|
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
|
||||||
WHERE cak.key = $1
|
WHERE cak.key = $1
|
||||||
AND (
|
AND cak."environmentId" = $4
|
||||||
(ca."valueNumber" IS NOT NULL AND ca."valueNumber" ${sqlOp} $2)
|
AND cak."dataType" = 'number'
|
||||||
OR
|
AND ca."valueNumber" IS NULL
|
||||||
(ca."valueNumber" IS NULL AND ca.value ~ $3 AND ca.value::double precision ${sqlOp} $2)
|
AND ca.value ~ $3
|
||||||
)
|
AND ca.value::double precision ${sqlOp} $2
|
||||||
`,
|
`,
|
||||||
contactAttributeKey,
|
contactAttributeKey,
|
||||||
numericValue,
|
numericValue,
|
||||||
NUMBER_PATTERN_SQL
|
NUMBER_PATTERN_SQL,
|
||||||
|
environmentId
|
||||||
);
|
);
|
||||||
|
|
||||||
const contactIds = matchingContactIds.map((r) => r.contactId);
|
if (unmigratedMatchingIds.length === 0) {
|
||||||
|
return migratedFilter;
|
||||||
if (contactIds.length === 0) {
|
|
||||||
// Return an impossible condition so the filter correctly excludes all contacts
|
|
||||||
return { id: "__NUMBER_FILTER_NO_MATCH__" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id: { in: contactIds } };
|
const contactIds = unmigratedMatchingIds.map((r) => r.contactId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
OR: [migratedFilter, { id: { in: contactIds } }],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Prisma where clause from a segment attribute filter
|
* Builds a Prisma where clause from a segment attribute filter
|
||||||
*/
|
*/
|
||||||
const buildAttributeFilterWhereClause = async (
|
const buildAttributeFilterWhereClause = async (
|
||||||
filter: TSegmentAttributeFilter
|
filter: TSegmentAttributeFilter,
|
||||||
|
environmentId: string
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root, qualifier, value } = filter;
|
const { root, qualifier, value } = filter;
|
||||||
const { contactAttributeKey } = root;
|
const { contactAttributeKey } = root;
|
||||||
@@ -215,7 +258,7 @@ const buildAttributeFilterWhereClause = async (
|
|||||||
|
|
||||||
// Handle number operators
|
// Handle number operators
|
||||||
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
|
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
|
||||||
return await buildNumberAttributeFilterWhereClause(filter);
|
return await buildNumberAttributeFilterWhereClause(filter, environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For string operators, ensure value is a primitive (not an object or array)
|
// For string operators, ensure value is a primitive (not an object or array)
|
||||||
@@ -253,7 +296,8 @@ const buildAttributeFilterWhereClause = async (
|
|||||||
* Builds a Prisma where clause from a person filter
|
* Builds a Prisma where clause from a person filter
|
||||||
*/
|
*/
|
||||||
const buildPersonFilterWhereClause = async (
|
const buildPersonFilterWhereClause = async (
|
||||||
filter: TSegmentPersonFilter
|
filter: TSegmentPersonFilter,
|
||||||
|
environmentId: string
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { personIdentifier } = filter.root;
|
const { personIdentifier } = filter.root;
|
||||||
|
|
||||||
@@ -265,7 +309,7 @@ const buildPersonFilterWhereClause = async (
|
|||||||
contactAttributeKey: personIdentifier,
|
contactAttributeKey: personIdentifier,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return await buildAttributeFilterWhereClause(personFilter);
|
return await buildAttributeFilterWhereClause(personFilter, environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@@ -314,6 +358,7 @@ const buildDeviceFilterWhereClause = (
|
|||||||
const buildSegmentFilterWhereClause = async (
|
const buildSegmentFilterWhereClause = async (
|
||||||
filter: TSegmentSegmentFilter,
|
filter: TSegmentSegmentFilter,
|
||||||
segmentPath: Set<string>,
|
segmentPath: Set<string>,
|
||||||
|
environmentId: string,
|
||||||
deviceType?: "phone" | "desktop"
|
deviceType?: "phone" | "desktop"
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root } = filter;
|
const { root } = filter;
|
||||||
@@ -337,7 +382,7 @@ const buildSegmentFilterWhereClause = async (
|
|||||||
const newPath = new Set(segmentPath);
|
const newPath = new Set(segmentPath);
|
||||||
newPath.add(segmentId);
|
newPath.add(segmentId);
|
||||||
|
|
||||||
return processFilters(segment.filters, newPath, deviceType);
|
return processFilters(segment.filters, newPath, environmentId, deviceType);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,19 +391,25 @@ const buildSegmentFilterWhereClause = async (
|
|||||||
const processSingleFilter = async (
|
const processSingleFilter = async (
|
||||||
filter: TSegmentFilter,
|
filter: TSegmentFilter,
|
||||||
segmentPath: Set<string>,
|
segmentPath: Set<string>,
|
||||||
|
environmentId: string,
|
||||||
deviceType?: "phone" | "desktop"
|
deviceType?: "phone" | "desktop"
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
const { root } = filter;
|
const { root } = filter;
|
||||||
|
|
||||||
switch (root.type) {
|
switch (root.type) {
|
||||||
case "attribute":
|
case "attribute":
|
||||||
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
|
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter, environmentId);
|
||||||
case "person":
|
case "person":
|
||||||
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
|
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter, environmentId);
|
||||||
case "device":
|
case "device":
|
||||||
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
|
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
|
||||||
case "segment":
|
case "segment":
|
||||||
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath, deviceType);
|
return await buildSegmentFilterWhereClause(
|
||||||
|
filter as TSegmentSegmentFilter,
|
||||||
|
segmentPath,
|
||||||
|
environmentId,
|
||||||
|
deviceType
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -370,6 +421,7 @@ const processSingleFilter = async (
|
|||||||
const processFilters = async (
|
const processFilters = async (
|
||||||
filters: TBaseFilters,
|
filters: TBaseFilters,
|
||||||
segmentPath: Set<string>,
|
segmentPath: Set<string>,
|
||||||
|
environmentId: string,
|
||||||
deviceType?: "phone" | "desktop"
|
deviceType?: "phone" | "desktop"
|
||||||
): Promise<Prisma.ContactWhereInput> => {
|
): Promise<Prisma.ContactWhereInput> => {
|
||||||
if (filters.length === 0) return {};
|
if (filters.length === 0) return {};
|
||||||
@@ -386,10 +438,10 @@ const processFilters = async (
|
|||||||
// Process the resource based on its type
|
// Process the resource based on its type
|
||||||
if (isResourceFilter(resource)) {
|
if (isResourceFilter(resource)) {
|
||||||
// If it's a single filter, process it directly
|
// If it's a single filter, process it directly
|
||||||
whereClause = await processSingleFilter(resource, segmentPath, deviceType);
|
whereClause = await processSingleFilter(resource, segmentPath, environmentId, deviceType);
|
||||||
} else {
|
} else {
|
||||||
// If it's a group of filters, process it recursively
|
// If it's a group of filters, process it recursively
|
||||||
whereClause = await processFilters(resource, segmentPath, deviceType);
|
whereClause = await processFilters(resource, segmentPath, environmentId, deviceType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(whereClause).length === 0) continue;
|
if (Object.keys(whereClause).length === 0) continue;
|
||||||
@@ -432,7 +484,7 @@ export const segmentFilterToPrismaQuery = reactCache(
|
|||||||
|
|
||||||
// Initialize an empty stack for tracking the current evaluation path
|
// Initialize an empty stack for tracking the current evaluation path
|
||||||
const segmentPath = new Set<string>([segmentId]);
|
const segmentPath = new Set<string>([segmentId]);
|
||||||
const filtersWhereClause = await processFilters(filters, segmentPath, deviceType);
|
const filtersWhereClause = await processFilters(filters, segmentPath, environmentId, deviceType);
|
||||||
|
|
||||||
const whereClause = {
|
const whereClause = {
|
||||||
AND: [baseWhereClause, filtersWhereClause],
|
AND: [baseWhereClause, filtersWhereClause],
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
survey: {
|
survey: {
|
||||||
@@ -206,6 +207,73 @@ describe("Segment Service Tests", () => {
|
|||||||
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
|
vi.mocked(prisma.segment.create).mockRejectedValue(new Error("DB error"));
|
||||||
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(Error);
|
await expect(createSegment(mockSegmentCreateInput)).rejects.toThrow(Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should upsert a private segment without surveyId", async () => {
|
||||||
|
const privateInput: TSegmentCreateInput = {
|
||||||
|
...mockSegmentCreateInput,
|
||||||
|
isPrivate: true,
|
||||||
|
};
|
||||||
|
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
|
||||||
|
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
|
||||||
|
const segment = await createSegment(privateInput);
|
||||||
|
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
|
||||||
|
expect(prisma.segment.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
environmentId_title: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInput.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInput.title,
|
||||||
|
description: undefined,
|
||||||
|
isPrivate: true,
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
description: undefined,
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
select: selectSegment,
|
||||||
|
});
|
||||||
|
expect(prisma.segment.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should upsert a private segment with surveyId", async () => {
|
||||||
|
const privateInputWithSurvey: TSegmentCreateInput = {
|
||||||
|
...mockSegmentCreateInput,
|
||||||
|
isPrivate: true,
|
||||||
|
surveyId,
|
||||||
|
};
|
||||||
|
const privateSegmentPrisma = { ...mockSegmentPrisma, isPrivate: true };
|
||||||
|
vi.mocked(prisma.segment.upsert).mockResolvedValue(privateSegmentPrisma);
|
||||||
|
const segment = await createSegment(privateInputWithSurvey);
|
||||||
|
expect(segment).toEqual({ ...mockSegment, isPrivate: true });
|
||||||
|
expect(prisma.segment.upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
environmentId_title: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInputWithSurvey.title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
environmentId,
|
||||||
|
title: privateInputWithSurvey.title,
|
||||||
|
description: undefined,
|
||||||
|
isPrivate: true,
|
||||||
|
filters: [],
|
||||||
|
surveys: { connect: { id: surveyId } },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
description: undefined,
|
||||||
|
filters: [],
|
||||||
|
surveys: { connect: { id: surveyId } },
|
||||||
|
},
|
||||||
|
select: selectSegment,
|
||||||
|
});
|
||||||
|
expect(prisma.segment.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cloneSegment", () => {
|
describe("cloneSegment", () => {
|
||||||
|
|||||||
@@ -136,28 +136,48 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
|
|||||||
|
|
||||||
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
|
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
|
||||||
|
|
||||||
let data: Prisma.SegmentCreateArgs["data"] = {
|
const surveyConnect = surveyId ? { surveys: { connect: { id: surveyId } } } : {};
|
||||||
environmentId,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
isPrivate,
|
|
||||||
filters,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (surveyId) {
|
|
||||||
data = {
|
|
||||||
...data,
|
|
||||||
surveys: {
|
|
||||||
connect: {
|
|
||||||
id: surveyId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Private segments use upsert because auto-save may have already created a
|
||||||
|
// default (empty-filter) segment via connectOrCreate before the user publishes.
|
||||||
|
// Without upsert the second create hits the (environmentId, title) unique constraint.
|
||||||
|
if (isPrivate) {
|
||||||
|
const segment = await prisma.segment.upsert({
|
||||||
|
where: {
|
||||||
|
environmentId_title: {
|
||||||
|
environmentId,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
environmentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
isPrivate,
|
||||||
|
filters,
|
||||||
|
...surveyConnect,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
description,
|
||||||
|
filters,
|
||||||
|
...surveyConnect,
|
||||||
|
},
|
||||||
|
select: selectSegment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformPrismaSegment(segment);
|
||||||
|
}
|
||||||
|
|
||||||
const segment = await prisma.segment.create({
|
const segment = await prisma.segment.create({
|
||||||
data,
|
data: {
|
||||||
|
environmentId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
isPrivate,
|
||||||
|
filters,
|
||||||
|
...surveyConnect,
|
||||||
|
},
|
||||||
select: selectSegment,
|
select: selectSegment,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const SegmentsPage = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ContactsPageLayout
|
<ContactsPageLayout
|
||||||
pageTitle="Contacts"
|
pageTitle={t("common.contacts")}
|
||||||
activeId="segments"
|
activeId="segments"
|
||||||
environmentId={params.environmentId}
|
environmentId={params.environmentId}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { generateWebhookSecret } from "@/lib/crypto";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
@@ -24,6 +26,7 @@ import { ZWebhookInput } from "@/modules/integrations/webhooks/types/webhooks";
|
|||||||
const ZCreateWebhookAction = z.object({
|
const ZCreateWebhookAction = z.object({
|
||||||
environmentId: ZId,
|
environmentId: ZId,
|
||||||
webhookInput: ZWebhookInput,
|
webhookInput: ZWebhookInput,
|
||||||
|
webhookSecret: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createWebhookAction = authenticatedActionClient.schema(ZCreateWebhookAction).action(
|
export const createWebhookAction = authenticatedActionClient.schema(ZCreateWebhookAction).action(
|
||||||
@@ -47,7 +50,11 @@ export const createWebhookAction = authenticatedActionClient.schema(ZCreateWebho
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const webhook = await createWebhook(parsedInput.environmentId, parsedInput.webhookInput);
|
const webhook = await createWebhook(
|
||||||
|
parsedInput.environmentId,
|
||||||
|
parsedInput.webhookInput,
|
||||||
|
parsedInput.webhookSecret
|
||||||
|
);
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
ctx.auditLoggingCtx.newObject = parsedInput.webhookInput;
|
ctx.auditLoggingCtx.newObject = parsedInput.webhookInput;
|
||||||
return webhook;
|
return webhook;
|
||||||
@@ -131,10 +138,43 @@ export const updateWebhookAction = authenticatedActionClient.schema(ZUpdateWebho
|
|||||||
|
|
||||||
const ZTestEndpointAction = z.object({
|
const ZTestEndpointAction = z.object({
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
|
webhookId: ZId.optional(),
|
||||||
|
secret: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const testEndpointAction = authenticatedActionClient
|
export const testEndpointAction = authenticatedActionClient
|
||||||
.schema(ZTestEndpointAction)
|
.schema(ZTestEndpointAction)
|
||||||
.action(async ({ parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
return testEndpoint(parsedInput.url);
|
let secret: string | undefined;
|
||||||
|
|
||||||
|
if (parsedInput.webhookId) {
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId: await getOrganizationIdFromWebhookId(parsedInput.webhookId),
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "read",
|
||||||
|
projectId: await getProjectIdFromWebhookId(parsedInput.webhookId),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookResult = await getWebhook(parsedInput.webhookId);
|
||||||
|
if (!webhookResult.ok) {
|
||||||
|
throw new ResourceNotFoundError("Webhook", parsedInput.webhookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
secret = webhookResult.data.secret ?? undefined;
|
||||||
|
} else {
|
||||||
|
// New webhook, use the provided secret or generate a new one
|
||||||
|
secret = parsedInput.secret ?? generateWebhookSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
await testEndpoint(parsedInput.url, secret);
|
||||||
|
return { success: true, secret };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,16 +53,22 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||||
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
|
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
|
||||||
|
const [webhookSecret, setWebhookSecret] = useState<string | undefined>();
|
||||||
|
|
||||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
const handleTestEndpoint = async (
|
||||||
|
sendSuccessToast: boolean
|
||||||
|
): Promise<{ success: boolean; secret?: string }> => {
|
||||||
try {
|
try {
|
||||||
const { valid, error } = validWebHookURL(testEndpointInput);
|
const { valid, error } = validWebHookURL(testEndpointInput);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
|
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
|
||||||
return;
|
return { success: false };
|
||||||
}
|
}
|
||||||
setHittingEndpoint(true);
|
setHittingEndpoint(true);
|
||||||
const testEndpointActionResult = await testEndpointAction({ url: testEndpointInput });
|
const testEndpointActionResult = await testEndpointAction({
|
||||||
|
url: testEndpointInput,
|
||||||
|
secret: webhookSecret,
|
||||||
|
});
|
||||||
if (!testEndpointActionResult?.data) {
|
if (!testEndpointActionResult?.data) {
|
||||||
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
@@ -70,7 +76,10 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
setHittingEndpoint(false);
|
setHittingEndpoint(false);
|
||||||
if (sendSuccessToast) toast.success(t("environments.integrations.webhooks.endpoint_pinged"));
|
if (sendSuccessToast) toast.success(t("environments.integrations.webhooks.endpoint_pinged"));
|
||||||
setEndpointAccessible(true);
|
setEndpointAccessible(true);
|
||||||
return true;
|
if (testEndpointActionResult.data.secret) {
|
||||||
|
setWebhookSecret(testEndpointActionResult.data.secret);
|
||||||
|
}
|
||||||
|
return testEndpointActionResult.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setHittingEndpoint(false);
|
setHittingEndpoint(false);
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -83,7 +92,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
);
|
);
|
||||||
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), err.message);
|
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), err.message);
|
||||||
setEndpointAccessible(false);
|
setEndpointAccessible(false);
|
||||||
return false;
|
return { success: false };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,8 +136,8 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
throw new Error(t("environments.integrations.webhooks.discord_webhook_not_supported"));
|
throw new Error(t("environments.integrations.webhooks.discord_webhook_not_supported"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
const testResult = await handleTestEndpoint(false);
|
||||||
if (!endpointHitSuccessfully) return;
|
if (!testResult.success) return;
|
||||||
|
|
||||||
const updatedData: TWebhookInput = {
|
const updatedData: TWebhookInput = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -141,6 +150,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
const createWebhookActionResult = await createWebhookAction({
|
const createWebhookActionResult = await createWebhookAction({
|
||||||
environmentId,
|
environmentId,
|
||||||
webhookInput: updatedData,
|
webhookInput: updatedData,
|
||||||
|
webhookSecret: testResult.secret,
|
||||||
});
|
});
|
||||||
if (createWebhookActionResult?.data) {
|
if (createWebhookActionResult?.data) {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -167,6 +177,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
setSelectedTriggers([]);
|
setSelectedTriggers([]);
|
||||||
setSelectedAllSurveys(false);
|
setSelectedAllSurveys(false);
|
||||||
setCreatedWebhook(null);
|
setCreatedWebhook(null);
|
||||||
|
setWebhookSecret(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show success dialog with secret after webhook creation
|
// Show success dialog with secret after webhook creation
|
||||||
|
|||||||
@@ -58,16 +58,19 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const { valid, error } = validWebHookURL(testEndpointInput);
|
const { valid, error } = validWebHookURL(testEndpointInput);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
|
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setHittingEndpoint(true);
|
setHittingEndpoint(true);
|
||||||
const testEndpointActionResult = await testEndpointAction({ url: testEndpointInput });
|
const testEndpointActionResult = await testEndpointAction({
|
||||||
if (!testEndpointActionResult?.data) {
|
url: testEndpointInput,
|
||||||
|
webhookId: webhook.id,
|
||||||
|
});
|
||||||
|
if (!testEndpointActionResult?.data?.success) {
|
||||||
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
|
className="absolute right-3 top-1/2 -translate-y-1/2 transform"
|
||||||
onClick={() => setShowSecret(!showSecret)}>
|
onClick={() => setShowSecret(!showSecret)}>
|
||||||
{showSecret ? (
|
{showSecret ? (
|
||||||
<EyeOff className="h-5 w-5 text-slate-400" />
|
<EyeOff className="h-5 w-5 text-slate-400" />
|
||||||
|
|||||||
@@ -61,19 +61,23 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
export const createWebhook = async (
|
||||||
|
environmentId: string,
|
||||||
|
webhookInput: TWebhookInput,
|
||||||
|
secret?: string
|
||||||
|
): Promise<Webhook> => {
|
||||||
try {
|
try {
|
||||||
if (isDiscordWebhook(webhookInput.url)) {
|
if (isDiscordWebhook(webhookInput.url)) {
|
||||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = generateWebhookSecret();
|
const signingSecret = secret ?? generateWebhookSecret();
|
||||||
|
|
||||||
const webhook = await prisma.webhook.create({
|
const webhook = await prisma.webhook.create({
|
||||||
data: {
|
data: {
|
||||||
...webhookInput,
|
...webhookInput,
|
||||||
surveyIds: webhookInput.surveyIds || [],
|
surveyIds: webhookInput.surveyIds || [],
|
||||||
secret,
|
secret: signingSecret,
|
||||||
environment: {
|
environment: {
|
||||||
connect: {
|
connect: {
|
||||||
id: environmentId,
|
id: environmentId,
|
||||||
@@ -118,7 +122,7 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const testEndpoint = async (url: string): Promise<boolean> => {
|
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
@@ -131,19 +135,25 @@ export const testEndpoint = async (url: string): Promise<boolean> => {
|
|||||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||||
const body = JSON.stringify({ event: "testEndpoint" });
|
const body = JSON.stringify({ event: "testEndpoint" });
|
||||||
|
|
||||||
// Generate a temporary test secret and signature for consistency with actual webhooks
|
const requestHeaders: Record<string, string> = {
|
||||||
const testSecret = generateWebhookSecret();
|
"Content-Type": "application/json",
|
||||||
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
|
"webhook-id": webhookMessageId,
|
||||||
|
"webhook-timestamp": webhookTimestamp.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (secret) {
|
||||||
|
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
||||||
|
webhookMessageId,
|
||||||
|
webhookTimestamp,
|
||||||
|
body,
|
||||||
|
secret
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"webhook-id": webhookMessageId,
|
|
||||||
"webhook-timestamp": webhookTimestamp.toString(),
|
|
||||||
"webhook-signature": signature,
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
|
import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
|
||||||
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
|
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
|
||||||
import { previewSurvey } from "@/app/lib/templates";
|
import { previewSurvey } from "@/app/lib/templates";
|
||||||
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
|
import {
|
||||||
|
COLOR_DEFAULTS,
|
||||||
|
STYLE_DEFAULTS,
|
||||||
|
deriveNewFieldsFromLegacy,
|
||||||
|
getSuggestedColors,
|
||||||
|
} from "@/lib/styling/constants";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||||
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
||||||
@@ -62,11 +67,23 @@ export const ThemeStyling = ({
|
|||||||
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
|
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
const legacyFills = deriveNewFieldsFromLegacy(cleanSaved);
|
||||||
|
|
||||||
const form = useForm<TProjectStyling>({
|
const form = useForm<TProjectStyling>({
|
||||||
defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved },
|
defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...cleanSaved },
|
||||||
resolver: zodResolver(ZProjectStyling),
|
resolver: zodResolver(ZProjectStyling),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Brand color shown in the preview. Only updated when the user triggers
|
||||||
|
// "Suggest colors", "Save", or "Reset to default" — NOT on every keystroke
|
||||||
|
// in the brand-color picker. This prevents the loading-spinner / progress
|
||||||
|
// bar from updating while the user is still picking a colour.
|
||||||
|
const [previewBrandColor, setPreviewBrandColor] = useState<string>(
|
||||||
|
(cleanSaved as Partial<TProjectStyling>).brandColor?.light ??
|
||||||
|
STYLE_DEFAULTS.brandColor?.light ??
|
||||||
|
COLOR_DEFAULTS.brandColor
|
||||||
|
);
|
||||||
|
|
||||||
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
|
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
|
||||||
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
|
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
|
||||||
const [confirmSuggestColorsOpen, setConfirmSuggestColorsOpen] = useState(false);
|
const [confirmSuggestColorsOpen, setConfirmSuggestColorsOpen] = useState(false);
|
||||||
@@ -84,6 +101,7 @@ export const ThemeStyling = ({
|
|||||||
|
|
||||||
if (updatedProjectResponse?.data) {
|
if (updatedProjectResponse?.data) {
|
||||||
form.reset({ ...STYLE_DEFAULTS });
|
form.reset({ ...STYLE_DEFAULTS });
|
||||||
|
setPreviewBrandColor(STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
|
||||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
@@ -100,7 +118,10 @@ export const ThemeStyling = ({
|
|||||||
form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true });
|
form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
// Commit brand color to the preview now that all derived colours are in sync.
|
||||||
|
setPreviewBrandColor(brandColor ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
|
||||||
|
|
||||||
|
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
|
||||||
setConfirmSuggestColorsOpen(false);
|
setConfirmSuggestColorsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,7 +134,11 @@ export const ThemeStyling = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (updatedProjectResponse?.data) {
|
if (updatedProjectResponse?.data) {
|
||||||
form.reset({ ...updatedProjectResponse.data.styling });
|
const saved = updatedProjectResponse.data.styling;
|
||||||
|
form.reset({ ...saved });
|
||||||
|
setPreviewBrandColor(
|
||||||
|
saved?.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor
|
||||||
|
);
|
||||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||||
@@ -249,7 +274,9 @@ export const ThemeStyling = ({
|
|||||||
survey={previewSurvey(project.name, t)}
|
survey={previewSurvey(project.name, t)}
|
||||||
project={{
|
project={{
|
||||||
...project,
|
...project,
|
||||||
styling: form.watch("allowStyleOverwrite") ? form.watch() : STYLE_DEFAULTS,
|
styling: form.watch("allowStyleOverwrite")
|
||||||
|
? { ...form.watch(), brandColor: { light: previewBrandColor } }
|
||||||
|
: STYLE_DEFAULTS,
|
||||||
}}
|
}}
|
||||||
previewType={previewSurveyType}
|
previewType={previewSurveyType}
|
||||||
setPreviewType={setPreviewSurveyType}
|
setPreviewType={setPreviewSurveyType}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
isValidFileTypeForExtension,
|
isValidFileTypeForExtension,
|
||||||
isValidImageFile,
|
isValidImageFile,
|
||||||
resolveStorageUrl,
|
resolveStorageUrl,
|
||||||
|
resolveStorageUrlAuto,
|
||||||
|
resolveStorageUrlsInObject,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
validateFileUploads,
|
validateFileUploads,
|
||||||
validateSingleFile,
|
validateSingleFile,
|
||||||
@@ -406,7 +408,7 @@ describe("storage utils", () => {
|
|||||||
expect(resolveStorageUrl("")).toBe("");
|
expect(resolveStorageUrl("")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return absolute URL unchanged (backward compatibility)", () => {
|
test("should return absolute URL unchanged", () => {
|
||||||
const httpsUrl = "https://example.com/storage/env-123/public/image.jpg";
|
const httpsUrl = "https://example.com/storage/env-123/public/image.jpg";
|
||||||
const httpUrl = "http://example.com/storage/env-123/public/image.jpg";
|
const httpUrl = "http://example.com/storage/env-123/public/image.jpg";
|
||||||
|
|
||||||
@@ -415,14 +417,12 @@ describe("storage utils", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should resolve relative /storage/ path to absolute URL", async () => {
|
test("should resolve relative /storage/ path to absolute URL", async () => {
|
||||||
// Use actual implementation with mocked dependencies
|
|
||||||
const { resolveStorageUrl: actualResolveStorageUrl } =
|
const { resolveStorageUrl: actualResolveStorageUrl } =
|
||||||
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
const relativePath = "/storage/env-123/public/image.jpg";
|
const relativePath = "/storage/env-123/public/image.jpg";
|
||||||
const result = actualResolveStorageUrl(relativePath);
|
const result = actualResolveStorageUrl(relativePath);
|
||||||
|
|
||||||
// Should prepend the base URL (from mocked WEBAPP_URL or getPublicDomain)
|
|
||||||
expect(result).toContain("/storage/env-123/public/image.jpg");
|
expect(result).toContain("/storage/env-123/public/image.jpg");
|
||||||
expect(result.startsWith("http")).toBe(true);
|
expect(result.startsWith("http")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -432,4 +432,209 @@ describe("storage utils", () => {
|
|||||||
expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg");
|
expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveStorageUrlAuto", () => {
|
||||||
|
test("should return non-storage strings unchanged", () => {
|
||||||
|
expect(resolveStorageUrlAuto("hello world")).toBe("hello world");
|
||||||
|
expect(resolveStorageUrlAuto("/some/other/path")).toBe("/some/other/path");
|
||||||
|
expect(resolveStorageUrlAuto("https://example.com/image.jpg")).toBe("https://example.com/image.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should NOT transform free-text values that merely start with /storage/", () => {
|
||||||
|
expect(resolveStorageUrlAuto("/storage/help")).toBe("/storage/help");
|
||||||
|
expect(resolveStorageUrlAuto("/storage/")).toBe("/storage/");
|
||||||
|
expect(resolveStorageUrlAuto("/storage/some-text")).toBe("/storage/some-text");
|
||||||
|
expect(resolveStorageUrlAuto("/storage/foo/bar")).toBe("/storage/foo/bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve public storage URL", async () => {
|
||||||
|
const { resolveStorageUrlAuto: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const result = actual("/storage/env-123/public/image.jpg");
|
||||||
|
expect(result).toContain("/storage/env-123/public/image.jpg");
|
||||||
|
expect(result.startsWith("http")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should detect private access type from URL path", () => {
|
||||||
|
const privateUrl = "/storage/env-123/private/file.pdf";
|
||||||
|
const publicUrl = "/storage/env-123/public/image.jpg";
|
||||||
|
|
||||||
|
expect(privateUrl.includes("/private/")).toBe(true);
|
||||||
|
expect(publicUrl.includes("/private/")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveStorageUrlsInObject", () => {
|
||||||
|
test("should return null and undefined as-is", () => {
|
||||||
|
expect(resolveStorageUrlsInObject(null)).toBeNull();
|
||||||
|
expect(resolveStorageUrlsInObject(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return primitive values unchanged", () => {
|
||||||
|
expect(resolveStorageUrlsInObject(42)).toBe(42);
|
||||||
|
expect(resolveStorageUrlsInObject(true)).toBe(true);
|
||||||
|
expect(resolveStorageUrlsInObject("hello")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should NOT transform free-text that merely starts with /storage/", () => {
|
||||||
|
expect(resolveStorageUrlsInObject("/storage/help")).toBe("/storage/help");
|
||||||
|
expect(resolveStorageUrlsInObject("/storage/")).toBe("/storage/");
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
questionId1: "/storage/",
|
||||||
|
questionId2: "/storage/help",
|
||||||
|
questionId3: "/storage/some-text",
|
||||||
|
questionId4: "/storage/foo/bar",
|
||||||
|
realUrl: "/storage/env-123/public/image.jpg",
|
||||||
|
};
|
||||||
|
const result = resolveStorageUrlsInObject(input);
|
||||||
|
expect(result.questionId1).toBe("/storage/");
|
||||||
|
expect(result.questionId2).toBe("/storage/help");
|
||||||
|
expect(result.questionId3).toBe("/storage/some-text");
|
||||||
|
expect(result.questionId4).toBe("/storage/foo/bar");
|
||||||
|
// realUrl still gets resolved because it matches the actual format
|
||||||
|
expect(result.realUrl).not.toBe("/storage/env-123/public/image.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve Date instances", () => {
|
||||||
|
const date = new Date("2026-01-01");
|
||||||
|
expect(resolveStorageUrlsInObject(date)).toBe(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve storage URL strings", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const result = actual("/storage/env-123/public/image.jpg");
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result).toContain("/storage/env-123/public/image.jpg");
|
||||||
|
expect((result as string).startsWith("http")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve URLs in arrays", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const input = ["/storage/env-123/public/a.jpg", "plain text"];
|
||||||
|
const result = actual(input);
|
||||||
|
|
||||||
|
expect(result[0]).toContain("/storage/env-123/public/a.jpg");
|
||||||
|
expect(result[0].startsWith("http")).toBe(true);
|
||||||
|
expect(result[1]).toBe("plain text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve URLs in nested objects", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
name: "Test Survey",
|
||||||
|
welcomeCard: {
|
||||||
|
fileUrl: "/storage/env-123/public/welcome.png",
|
||||||
|
headline: "Hello",
|
||||||
|
},
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
imageUrl: "/storage/env-123/public/q1.jpg",
|
||||||
|
choices: [
|
||||||
|
{ id: "c1", imageUrl: "/storage/env-123/public/choice1.jpg" },
|
||||||
|
{ id: "c2", imageUrl: "https://external.com/image.jpg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
count: 5,
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = actual(input);
|
||||||
|
|
||||||
|
expect(result.welcomeCard.fileUrl.startsWith("http")).toBe(true);
|
||||||
|
expect(result.welcomeCard.headline).toBe("Hello");
|
||||||
|
expect(result.elements[0].imageUrl.startsWith("http")).toBe(true);
|
||||||
|
expect(result.elements[0].choices[0].imageUrl.startsWith("http")).toBe(true);
|
||||||
|
expect(result.elements[0].choices[1].imageUrl).toBe("https://external.com/image.jpg");
|
||||||
|
expect(result.count).toBe(5);
|
||||||
|
expect(result.createdAt).toEqual(new Date("2026-01-01"));
|
||||||
|
expect(result.name).toBe("Test Survey");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve URLs in deeply nested objects", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
level4: {
|
||||||
|
level5: {
|
||||||
|
imageUrl: "/storage/env-123/public/deep.png",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
nested: {
|
||||||
|
url: "/storage/env-123/public/nested.jpg",
|
||||||
|
label: "keep me",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plain string",
|
||||||
|
42,
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sibling: "/storage/env-123/public/sibling.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
untouched: { a: { b: { c: "no change" } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = actual(input);
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.level4.level5.imageUrl).toContain(
|
||||||
|
"/storage/env-123/public/deep.png"
|
||||||
|
);
|
||||||
|
expect(result.level1.level2.level3.level4.level5.imageUrl.startsWith("http")).toBe(true);
|
||||||
|
|
||||||
|
// @ts-expect-error - items is an array of unknown types
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[0].nested.url).toContain(
|
||||||
|
"/storage/env-123/public/nested.jpg"
|
||||||
|
);
|
||||||
|
// @ts-expect-error - items is an array of unknown types
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[0].nested.url.startsWith("http")).toBe(true);
|
||||||
|
// @ts-expect-error - items is an array of unknown types
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[0].nested.label).toBe("keep me");
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[1]).toBe("plain string");
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[2]).toBe(42);
|
||||||
|
expect(result.level1.level2.level3.level4.level5.items[3]).toBeNull();
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.sibling).toContain("/storage/env-123/public/sibling.png");
|
||||||
|
expect(result.level1.level2.level3.sibling.startsWith("http")).toBe(true);
|
||||||
|
|
||||||
|
expect(result.level1.untouched.a.b.c).toBe("no change");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle response data with file upload URLs", async () => {
|
||||||
|
const { resolveStorageUrlsInObject: actual } =
|
||||||
|
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
questionId1: "text answer",
|
||||||
|
questionId2: 42,
|
||||||
|
fileUploadId: ["/storage/env-123/public/doc.pdf", "/storage/env-123/public/img.png"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = actual(responseData);
|
||||||
|
|
||||||
|
expect(result.questionId1).toBe("text answer");
|
||||||
|
expect(result.questionId2).toBe(42);
|
||||||
|
const fileUrls = result.fileUploadId;
|
||||||
|
expect(fileUrls[0]).toContain("/storage/env-123/public/doc.pdf");
|
||||||
|
expect(fileUrls[0].startsWith("http")).toBe(true);
|
||||||
|
expect(fileUrls[1]).toContain("/storage/env-123/public/img.png");
|
||||||
|
expect(fileUrls[1].startsWith("http")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a storage URL to an absolute URL.
|
* Resolves a storage URL to an absolute URL.
|
||||||
* - If already absolute, returns as-is (backward compatibility for old data)
|
* - If already absolute, returns as-is
|
||||||
* - If relative (/storage/...), prepends the appropriate base URL
|
* - If relative (/storage/...), prepends the appropriate base URL
|
||||||
* @param url The storage URL (relative or absolute)
|
* @param url The storage URL (relative or absolute)
|
||||||
* @param accessType The access type to determine which base URL to use (defaults to "public")
|
* @param accessType The access type to determine which base URL to use (defaults to "public")
|
||||||
@@ -163,7 +163,7 @@ export const resolveStorageUrl = (
|
|||||||
): string => {
|
): string => {
|
||||||
if (!url) return "";
|
if (!url) return "";
|
||||||
|
|
||||||
// Already absolute URL - return as-is (backward compatibility for old data)
|
// Already absolute URL - return as-is
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -176,3 +176,41 @@ export const resolveStorageUrl = (
|
|||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Matches the actual storage URL format: /storage/{id}/{public|private}/{filename...}
|
||||||
|
const STORAGE_URL_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+/;
|
||||||
|
|
||||||
|
const isStorageUrl = (value: string): boolean => STORAGE_URL_PATTERN.test(value);
|
||||||
|
|
||||||
|
export const resolveStorageUrlAuto = (url: string): string => {
|
||||||
|
if (!isStorageUrl(url)) return url;
|
||||||
|
const accessType = url.includes("/private/") ? "private" : "public";
|
||||||
|
return resolveStorageUrl(url, accessType);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walks an object/array and resolves all relative storage URLs
|
||||||
|
* Preserves the original structure; skips Date instances and non-object primitives.
|
||||||
|
*/
|
||||||
|
export const resolveStorageUrlsInObject = <T>(obj: T): T => {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
return resolveStorageUrlAuto(obj) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj !== "object") return obj;
|
||||||
|
|
||||||
|
if (obj instanceof Date) return obj;
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => resolveStorageUrlsInObject(item)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
result[key] = resolveStorageUrlsInObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import toast from "react-hot-toast";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TProjectStyling } from "@formbricks/types/project";
|
import { TProjectStyling } from "@formbricks/types/project";
|
||||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||||
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
|
import { STYLE_DEFAULTS, deriveNewFieldsFromLegacy, getSuggestedColors } from "@/lib/styling/constants";
|
||||||
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
||||||
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
|
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
|
||||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||||
@@ -68,10 +68,15 @@ export const StylingView = ({
|
|||||||
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
|
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject);
|
||||||
|
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
|
||||||
|
|
||||||
const form = useForm<TSurveyStyling>({
|
const form = useForm<TSurveyStyling>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...STYLE_DEFAULTS,
|
...STYLE_DEFAULTS,
|
||||||
|
...projectLegacyFills,
|
||||||
...cleanProject,
|
...cleanProject,
|
||||||
|
...surveyLegacyFills,
|
||||||
...cleanSurvey,
|
...cleanSurvey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -94,7 +99,7 @@ export const StylingView = ({
|
|||||||
form.setValue(key as keyof TSurveyStyling, value, { shouldDirty: true });
|
form.setValue(key as keyof TSurveyStyling, value, { shouldDirty: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
|
||||||
setConfirmSuggestColorsOpen(false);
|
setConfirmSuggestColorsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-controls="options"
|
aria-controls="options"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn("flex h-full w-full cursor-pointer items-center justify-end bg-white pr-2", {
|
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
|
||||||
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
|
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
|
||||||
"pointer-events-none": isClearing,
|
"pointer-events-none": isClearing,
|
||||||
})}>
|
})}>
|
||||||
|
|||||||
@@ -225,10 +225,10 @@ export const PreviewSurvey = ({
|
|||||||
)}>
|
)}>
|
||||||
{previewMode === "mobile" && (
|
{previewMode === "mobile" && (
|
||||||
<>
|
<>
|
||||||
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
||||||
Preview
|
Preview
|
||||||
</p>
|
</p>
|
||||||
<div className="absolute top-0 right-0 m-2">
|
<div className="absolute right-0 top-0 m-2">
|
||||||
<ResetProgressButton onClick={resetProgress} />
|
<ResetProgressButton onClick={resetProgress} />
|
||||||
</div>
|
</div>
|
||||||
<MediaBackground
|
<MediaBackground
|
||||||
@@ -265,7 +265,7 @@ export const PreviewSurvey = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full flex-col justify-center px-1">
|
<div className="flex h-full w-full flex-col justify-center px-1">
|
||||||
<div className="absolute top-5 left-5">
|
<div className="absolute left-5 top-5">
|
||||||
{!styling.isLogoHidden && (
|
{!styling.isLogoHidden && (
|
||||||
<ClientLogo
|
<ClientLogo
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
@@ -296,7 +296,7 @@ export const PreviewSurvey = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{previewMode === "desktop" && (
|
{previewMode === "desktop" && (
|
||||||
<div className="flex h-full flex-1 flex-col">
|
<div className="flex h-full w-full flex-1 flex-col">
|
||||||
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
|
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
|
||||||
<div className="ml-6 flex space-x-2">
|
<div className="ml-6 flex space-x-2">
|
||||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||||
@@ -373,7 +373,7 @@ export const PreviewSurvey = ({
|
|||||||
styling={styling}
|
styling={styling}
|
||||||
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
|
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
|
||||||
isEditorView>
|
isEditorView>
|
||||||
<div className="absolute top-5 left-5">
|
<div className="absolute left-5 top-5">
|
||||||
{!styling.isLogoHidden && (
|
{!styling.isLogoHidden && (
|
||||||
<ClientLogo
|
<ClientLogo
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
|||||||
isLoadingScript = true;
|
isLoadingScript = true;
|
||||||
try {
|
try {
|
||||||
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
|
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
|
||||||
const response = await fetch(scriptUrl);
|
const response = await fetch(
|
||||||
|
scriptUrl,
|
||||||
|
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to load the surveys package");
|
throw new Error("Failed to load the surveys package");
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ test.describe("Survey Styling", async () => {
|
|||||||
expect(css).toContain("--fb-input-background-color: #eeeeee");
|
expect(css).toContain("--fb-input-background-color: #eeeeee");
|
||||||
expect(css).toContain("--fb-input-border-color: #cccccc");
|
expect(css).toContain("--fb-input-border-color: #cccccc");
|
||||||
expect(css).toContain("--fb-input-text-color: #024eff");
|
expect(css).toContain("--fb-input-text-color: #024eff");
|
||||||
|
expect(css).toContain("--fb-input-placeholder-color:");
|
||||||
expect(css).toContain("--fb-input-border-radius: 5px");
|
expect(css).toContain("--fb-input-border-radius: 5px");
|
||||||
expect(css).toContain("--fb-input-height: 50px");
|
expect(css).toContain("--fb-input-height: 50px");
|
||||||
expect(css).toContain("--fb-input-font-size: 16px");
|
expect(css).toContain("--fb-input-font-size: 16px");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
environmentMatchGlobs: [["**/*.test.tsx", "jsdom"]],
|
environmentMatchGlobs: [["**/*.test.tsx", "jsdom"]],
|
||||||
exclude: ["playwright/**", "node_modules/**"],
|
exclude: ["playwright/**", "node_modules/**", ".next/**"],
|
||||||
setupFiles: ["./vitestSetup.ts"],
|
setupFiles: ["./vitestSetup.ts"],
|
||||||
env: loadEnv("", process.cwd(), ""),
|
env: loadEnv("", process.cwd(), ""),
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Scaling:
|
|||||||
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
||||||
```
|
```
|
||||||
{{- else }}
|
{{- else }}
|
||||||
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.replicaCount }}` replicas.
|
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.deployment.replicas }}` replicas.
|
||||||
Manually scale using:
|
Manually scale using:
|
||||||
```sh
|
```sh
|
||||||
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
|
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
|
||||||
@@ -127,6 +127,34 @@ Scaling:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Pod Disruption Budget:
|
||||||
|
|
||||||
|
{{- if .Values.pdb.enabled }}
|
||||||
|
A PodDisruptionBudget is active to protect against voluntary disruptions.
|
||||||
|
{{- if not (kindIs "invalid" .Values.pdb.minAvailable) }}
|
||||||
|
- **Min Available**: `{{ .Values.pdb.minAvailable }}`
|
||||||
|
{{- end }}
|
||||||
|
{{- if not (kindIs "invalid" .Values.pdb.maxUnavailable) }}
|
||||||
|
- **Max Unavailable**: `{{ .Values.pdb.maxUnavailable }}`
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
Check PDB status:
|
||||||
|
```sh
|
||||||
|
kubectl get pdb -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
||||||
|
```
|
||||||
|
{{- if and .Values.autoscaling.enabled (eq (int .Values.autoscaling.minReplicas) 1) }}
|
||||||
|
|
||||||
|
WARNING: autoscaling.minReplicas is 1. With minAvailable: 1, the PDB
|
||||||
|
will block all node drains when only 1 replica is running. Set
|
||||||
|
autoscaling.minReplicas to at least 2 for proper HA protection.
|
||||||
|
{{- end }}
|
||||||
|
{{- else }}
|
||||||
|
PDB is **not enabled**. Voluntary disruptions (node drains, upgrades) may
|
||||||
|
take down all pods simultaneously.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
External Secrets:
|
External Secrets:
|
||||||
{{- if .Values.externalSecret.enabled }}
|
{{- if .Values.externalSecret.enabled }}
|
||||||
External secrets are enabled.
|
External secrets are enabled.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{{- if .Values.pdb.enabled }}
|
||||||
|
{{- $hasMinAvailable := not (kindIs "invalid" .Values.pdb.minAvailable) -}}
|
||||||
|
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.pdb.maxUnavailable) -}}
|
||||||
|
{{- if and $hasMinAvailable $hasMaxUnavailable }}
|
||||||
|
{{- fail "pdb.minAvailable and pdb.maxUnavailable are mutually exclusive; set only one" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
|
||||||
|
{{- fail "pdb.enabled is true but neither pdb.minAvailable nor pdb.maxUnavailable is set; set exactly one" }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: {{ template "formbricks.name" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "formbricks.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.pdb.additionalLabels }}
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.pdb.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml .Values.pdb.annotations | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if $hasMinAvailable }}
|
||||||
|
minAvailable: {{ .Values.pdb.minAvailable }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if $hasMaxUnavailable }}
|
||||||
|
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.pdb.unhealthyPodEvictionPolicy }}
|
||||||
|
unhealthyPodEvictionPolicy: {{ .Values.pdb.unhealthyPodEvictionPolicy }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "formbricks.selectorLabels" . | nindent 6 }}
|
||||||
|
{{- end }}
|
||||||
@@ -214,6 +214,42 @@ autoscaling:
|
|||||||
value: 2
|
value: 2
|
||||||
periodSeconds: 60 # Add at most 2 pods every minute
|
periodSeconds: 60 # Add at most 2 pods every minute
|
||||||
|
|
||||||
|
##########################################################
|
||||||
|
# Pod Disruption Budget (PDB)
|
||||||
|
#
|
||||||
|
# Ensures a minimum number of pods remain available during
|
||||||
|
# voluntary disruptions (node drains, cluster upgrades, etc.).
|
||||||
|
#
|
||||||
|
# IMPORTANT:
|
||||||
|
# - minAvailable and maxUnavailable are MUTUALLY EXCLUSIVE.
|
||||||
|
# Setting both will cause a helm install/upgrade failure.
|
||||||
|
# To switch, set the unused one to null in your override file.
|
||||||
|
# - Accepts an integer (e.g., 1) or a percentage string (e.g., "25%").
|
||||||
|
# - For PDB to provide real HA protection, ensure
|
||||||
|
# autoscaling.minReplicas >= 2 (or deployment.replicas >= 2
|
||||||
|
# if HPA is disabled). With only 1 replica and minAvailable: 1,
|
||||||
|
# the PDB will block ALL node drains and cluster upgrades.
|
||||||
|
##########################################################
|
||||||
|
pdb:
|
||||||
|
enabled: true
|
||||||
|
additionalLabels: {}
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
# Minimum pods that must remain available during disruptions.
|
||||||
|
# Set to null and configure maxUnavailable instead if preferred.
|
||||||
|
minAvailable: 1
|
||||||
|
|
||||||
|
# Maximum pods that can be unavailable during disruptions.
|
||||||
|
# Mutually exclusive with minAvailable — uncomment and set
|
||||||
|
# minAvailable to null to use this instead.
|
||||||
|
# maxUnavailable: 1
|
||||||
|
|
||||||
|
# Eviction policy for unhealthy pods (Kubernetes 1.27+).
|
||||||
|
# "IfHealthy" — unhealthy pods count toward the budget (default).
|
||||||
|
# "AlwaysAllow" — unhealthy pods can always be evicted,
|
||||||
|
# preventing them from blocking node drain.
|
||||||
|
# unhealthyPodEvictionPolicy: AlwaysAllow
|
||||||
|
|
||||||
##########################################################
|
##########################################################
|
||||||
# Service Configuration
|
# Service Configuration
|
||||||
##########################################################
|
##########################################################
|
||||||
|
|||||||
@@ -4,12 +4,182 @@ description: "Formbricks Self-hosted version migration"
|
|||||||
icon: "arrow-right"
|
icon: "arrow-right"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v4.7
|
||||||
|
|
||||||
|
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
|
||||||
|
|
||||||
|
### What Happens Automatically
|
||||||
|
|
||||||
|
When Formbricks v4.7 starts for the first time, the data migration will:
|
||||||
|
|
||||||
|
1. Analyze all existing contact attribute keys and infer their data types (`text`, `number`, or `date`) based on the stored values
|
||||||
|
2. Update the `ContactAttributeKey` table with the detected `dataType` for each key
|
||||||
|
3. **If your instance has fewer than 1,000,000 contact attribute rows**: backfill the new `valueNumber` and `valueDate` columns inline. No manual action is needed.
|
||||||
|
4. **If your instance has 1,000,000 or more contact attribute rows**: the value backfill is skipped to avoid hitting the migration timeout. You will need to run a standalone backfill script after the upgrade.
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
Most self-hosted instances have far fewer than 1,000,000 contact attribute rows (a typical setup with 100K
|
||||||
|
contacts and 5-10 attributes each lands around 500K-1M rows). If you are below the threshold, the migration
|
||||||
|
handles everything automatically and you can skip the manual backfill step below.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
### Steps to Migrate
|
||||||
|
|
||||||
|
**1. Backup your Database**
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
Before running these steps, navigate to the `formbricks` directory where your `docker-compose.yml` file is located.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||||
|
`formbricks_postgres_1`.
|
||||||
|
</Info>
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -n formbricks formbricks-postgresql-0 -- pg_dump -Fc -U formbricks -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
If your PostgreSQL pod has a different name, run `kubectl get pods -n formbricks` to find it.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
**2. Upgrade to Formbricks v4.7**
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
```bash
|
||||||
|
# Pull the latest version
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# Stop the current instance
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Start with Formbricks v4.7
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
```bash
|
||||||
|
helm upgrade formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \
|
||||||
|
-n formbricks \
|
||||||
|
--set deployment.image.tag=v4.7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
|
||||||
|
PreSync hook before the new pods start. No manual migration step is needed.
|
||||||
|
</Info>
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
**3. Check the Migration Logs**
|
||||||
|
|
||||||
|
After Formbricks starts, check the logs to see whether the value backfill was completed or skipped:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
```bash
|
||||||
|
docker compose logs formbricks | grep -i "backfill"
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
```bash
|
||||||
|
# Check the application pod logs
|
||||||
|
kubectl logs -n formbricks -l app.kubernetes.io/name=formbricks --tail=200 | grep -i "backfill"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the Helm migration Job ran, you can also inspect its logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl logs -n formbricks job/formbricks-migration
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
If you see a message like `Skipping value backfill (X rows >= 1000000 threshold)`, proceed to step 4. Otherwise, the migration is complete and no further action is needed.
|
||||||
|
|
||||||
|
**4. Run the Backfill Script (large datasets only)**
|
||||||
|
|
||||||
|
If the migration skipped the value backfill, run the standalone backfill script inside the running Formbricks container:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Docker">
|
||||||
|
```bash
|
||||||
|
docker exec formbricks node packages/database/dist/scripts/backfill-attribute-values.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Kubernetes">
|
||||||
|
```bash
|
||||||
|
kubectl exec -n formbricks deploy/formbricks -- node packages/database/dist/scripts/backfill-attribute-values.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
|
||||||
|
</Info>
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
The script will output progress as it runs:
|
||||||
|
|
||||||
|
```
|
||||||
|
========================================
|
||||||
|
Attribute Value Backfill Script
|
||||||
|
========================================
|
||||||
|
|
||||||
|
Fetching number-type attribute keys...
|
||||||
|
Found 12 number-type keys. Backfilling valueNumber...
|
||||||
|
Number backfill progress: 10/12 keys (48230 rows updated)
|
||||||
|
Number backfill progress: 12/12 keys (52104 rows updated)
|
||||||
|
|
||||||
|
Fetching date-type attribute keys...
|
||||||
|
Found 5 date-type keys. Backfilling valueDate...
|
||||||
|
Date backfill progress: 5/5 keys (31200 rows updated)
|
||||||
|
|
||||||
|
========================================
|
||||||
|
Backfill Complete!
|
||||||
|
========================================
|
||||||
|
valueNumber rows updated: 52104
|
||||||
|
valueDate rows updated: 31200
|
||||||
|
Duration: 42.3s
|
||||||
|
========================================
|
||||||
|
```
|
||||||
|
|
||||||
|
Key characteristics of the backfill script:
|
||||||
|
|
||||||
|
- **Safe to run while Formbricks is live** -- it does not lock the entire table or wrap work in a long transaction
|
||||||
|
- **Idempotent** -- it only updates rows where the typed columns are still `NULL`, so you can safely run it multiple times
|
||||||
|
- **Resumable** -- each batch commits independently, so if the process is interrupted you can re-run it and it picks up where it left off
|
||||||
|
- **No timeout risk** -- unlike the migration, this script runs outside the migration transaction and has no time limit
|
||||||
|
|
||||||
|
**5. Verify the Upgrade**
|
||||||
|
|
||||||
|
- Access your Formbricks instance at the same URL as before
|
||||||
|
- If you use contact segments with number or date filters, verify they return the expected results
|
||||||
|
- Check that existing surveys and response data are intact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v4.0
|
## v4.0
|
||||||
|
|
||||||
<Warning>
|
<Warning>
|
||||||
**Important: Migration Required**
|
**Important: Migration Required**
|
||||||
|
|
||||||
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
||||||
|
|
||||||
</Warning>
|
</Warning>
|
||||||
|
|
||||||
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
|
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
|
||||||
@@ -17,9 +187,11 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
|
|||||||
### What's New in Formbricks 4.0
|
### What's New in Formbricks 4.0
|
||||||
|
|
||||||
**🚀 New Enterprise Features:**
|
**🚀 New Enterprise Features:**
|
||||||
|
|
||||||
- **Quotas Management**: Advanced quota controls for enterprise users
|
- **Quotas Management**: Advanced quota controls for enterprise users
|
||||||
|
|
||||||
**🏗️ Technical Foundation Improvements:**
|
**🏗️ Technical Foundation Improvements:**
|
||||||
|
|
||||||
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
||||||
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
||||||
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
||||||
@@ -39,7 +211,8 @@ These services are already included in the updated one-click setup for self-host
|
|||||||
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
|
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
|
||||||
|
|
||||||
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
|
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
|
||||||
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
|
||||||
|
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
||||||
- **Advanced features** that require sophisticated caching and file processing
|
- **Advanced features** that require sophisticated caching and file processing
|
||||||
- **Better performance** through optimized, dedicated services
|
- **Better performance** through optimized, dedicated services
|
||||||
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
|
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
|
||||||
@@ -52,7 +225,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
|
|||||||
|
|
||||||
### One-Click Setup
|
### One-Click Setup
|
||||||
|
|
||||||
For users using our official one-click setup, we provide an automated migration using a migration script:
|
For users using our official one-click setup, we provide an automated migration using a migration script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download the latest script
|
# Download the latest script
|
||||||
@@ -67,11 +240,11 @@ chmod +x migrate-to-v4.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
This script guides you through the steps for the infrastructure migration and does the following:
|
This script guides you through the steps for the infrastructure migration and does the following:
|
||||||
|
|
||||||
- Adds a Redis service to your setup and configures it
|
- Adds a Redis service to your setup and configures it
|
||||||
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
|
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
|
||||||
- Pulls the latest Formbricks image and updates your instance
|
- Pulls the latest Formbricks image and updates your instance
|
||||||
|
|
||||||
|
|
||||||
### Manual Setup
|
### Manual Setup
|
||||||
|
|
||||||
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
|
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
|
||||||
@@ -87,6 +260,7 @@ You need to configure the `REDIS_URL` environment variable and point it to your
|
|||||||
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
|
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
|
||||||
|
|
||||||
Formbricks supports multiple storage providers (among many other S3-compatible storages):
|
Formbricks supports multiple storage providers (among many other S3-compatible storages):
|
||||||
|
|
||||||
- AWS S3
|
- AWS S3
|
||||||
- Digital Ocean Spaces
|
- Digital Ocean Spaces
|
||||||
- Hetzner Object Storage
|
- Hetzner Object Storage
|
||||||
@@ -101,6 +275,7 @@ Please make sure to set up a storage bucket with one of these solutions and then
|
|||||||
S3_BUCKET_NAME: formbricks-uploads
|
S3_BUCKET_NAME: formbricks-uploads
|
||||||
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Upgrade Process
|
#### Upgrade Process
|
||||||
|
|
||||||
**1. Backup your Database**
|
**1. Backup your Database**
|
||||||
@@ -112,8 +287,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Info>
|
<Info>
|
||||||
If you run into "**No such container**", use `docker ps` to find your container name,
|
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||||
e.g. `formbricks_postgres_1`.
|
`formbricks_postgres_1`.
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
**2. Upgrade to Formbricks 4.0**
|
**2. Upgrade to Formbricks 4.0**
|
||||||
@@ -134,6 +309,7 @@ docker compose up -d
|
|||||||
**3. Automatic Database Migration**
|
**3. Automatic Database Migration**
|
||||||
|
|
||||||
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
||||||
|
|
||||||
- Detect and apply required database schema updates
|
- Detect and apply required database schema updates
|
||||||
- Remove unused database tables and fields
|
- Remove unused database tables and fields
|
||||||
- Optimize the database structure for better performance
|
- Optimize the database structure for better performance
|
||||||
|
|||||||
@@ -1,41 +1,94 @@
|
|||||||
---
|
---
|
||||||
title: "Rate Limiting"
|
title: "Rate Limiting"
|
||||||
description: "Rate limiting for Formbricks"
|
description: "Current request rate limits in Formbricks"
|
||||||
icon: "timer"
|
icon: "timer"
|
||||||
---
|
---
|
||||||
|
|
||||||
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
|
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
|
||||||
|
|
||||||
## Default Rate Limits
|
Rate limits are scoped by identifier, depending on the endpoint:
|
||||||
|
|
||||||
The following rate limits apply to various endpoints:
|
- IP hash (for unauthenticated/client-side routes and public actions)
|
||||||
|
- API key ID (for authenticated API calls)
|
||||||
|
- User ID (for authenticated session-based calls and server actions)
|
||||||
|
- Organization ID (for follow-up email dispatch)
|
||||||
|
|
||||||
| **Endpoint** | **Rate Limit** | **Time Window** |
|
When a limit is exceeded, the API returns `429 Too Many Requests`.
|
||||||
| ----------------------- | -------------- | --------------- |
|
|
||||||
| `POST /login` | 30 requests | 15 minutes |
|
|
||||||
| `POST /signup` | 30 requests | 60 minutes |
|
|
||||||
| `POST /verify-email` | 10 requests | 60 minutes |
|
|
||||||
| `POST /forgot-password` | 5 requests | 60 minutes |
|
|
||||||
| `GET /client-side-api` | 100 requests | 1 minute |
|
|
||||||
| `POST /share` | 100 requests | 60 minutes |
|
|
||||||
|
|
||||||
If a request exceeds the defined rate limit, the server will respond with:
|
## Management API Rate Limits
|
||||||
|
|
||||||
|
These are the current limits for Management APIs:
|
||||||
|
|
||||||
|
| **Route Group** | **Limit** | **Window** | **Identifier** |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
|
||||||
|
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
|
||||||
|
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
|
||||||
|
|
||||||
|
## All Enforced Limits
|
||||||
|
|
||||||
|
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
|
||||||
|
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
|
||||||
|
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
|
||||||
|
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
|
||||||
|
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
|
||||||
|
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
|
||||||
|
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
|
||||||
|
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
|
||||||
|
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
|
||||||
|
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
|
||||||
|
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
|
||||||
|
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
|
||||||
|
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
|
||||||
|
|
||||||
|
## Current Endpoint Exceptions
|
||||||
|
|
||||||
|
The following routes are currently not rate-limited by the server-side limiter:
|
||||||
|
|
||||||
|
- `GET /api/v1/client/og` (explicitly excluded)
|
||||||
|
- `POST /api/v2/client/[environmentId]/responses`
|
||||||
|
- `POST /api/v2/client/[environmentId]/displays`
|
||||||
|
- `GET /api/v2/health`
|
||||||
|
|
||||||
|
## 429 Response Shape
|
||||||
|
|
||||||
|
v1-style endpoints return:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 429,
|
"code": "too_many_requests",
|
||||||
"error": "Too many requests, Please try after a while!"
|
"message": "Maximum number of requests reached. Please try again later.",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
v2-style endpoints return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": 429,
|
||||||
|
"message": "Too Many Requests"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Disabling Rate Limiting
|
## Disabling Rate Limiting
|
||||||
|
|
||||||
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
|
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
|
||||||
|
|
||||||
To disable rate limiting, set the following environment variable:
|
Set:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RATE_LIMITING_DISABLED=1
|
RATE_LIMITING_DISABLED=1
|
||||||
```
|
```
|
||||||
|
|
||||||
After making this change, restart your server to apply the new setting.
|
After changing this value, restart the server.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
|
||||||
|
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
|
||||||
|
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ When PUBLIC_URL is configured, the following routes are automatically served fro
|
|||||||
|
|
||||||
- `/s/{surveyId}` - Individual survey access
|
- `/s/{surveyId}` - Individual survey access
|
||||||
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
|
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
|
||||||
|
- `/p/{survey-slug}` - Pretty URL survey access
|
||||||
- Embedded survey endpoints
|
- Embedded survey endpoints
|
||||||
|
|
||||||
#### API Routes
|
#### API Routes
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ The Churn Survey is among the most effective ways to identify weaknesses in your
|
|||||||
|
|
||||||
* Follow-up to prevent bad reviews
|
* Follow-up to prevent bad reviews
|
||||||
|
|
||||||
* Coming soon: Make survey mandatory
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
To run the Churn Survey in your app you want to proceed as follows:
|
To run the Churn Survey in your app you want to proceed as follows:
|
||||||
@@ -80,13 +78,6 @@ Whenever a user visits this page, matches the filter conditions above and the re
|
|||||||
|
|
||||||
Here is our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions/) covering [No-Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) and [Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) Actions.
|
Here is our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions/) covering [No-Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) and [Code](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) Actions.
|
||||||
|
|
||||||
<Note>
|
|
||||||
Pre-churn flow coming soon We’re currently building full-screen survey
|
|
||||||
pop-ups. You’ll be able to prevent users from closing the survey unless they
|
|
||||||
respond to it. It’s certainly debatable if you want that but you could force
|
|
||||||
them to click through the survey before letting them cancel 🤷
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
### 5. Select Action in the “When to ask” card
|
### 5. Select Action in the “When to ask” card
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -46,13 +46,7 @@ _Want to change the button color? Adjust it in the project settings!_
|
|||||||
|
|
||||||
Save, and move over to the **Audience** tab.
|
Save, and move over to the **Audience** tab.
|
||||||
|
|
||||||
### 3. Pre-segment your audience (coming soon)
|
### 3. Pre-segment your audience
|
||||||
|
|
||||||
<Note>
|
|
||||||
### Filter by Attribute Coming Soon
|
|
||||||
|
|
||||||
We're working on pre-segmenting users by attributes. This manual will be updated in the coming days.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
Pre-segmentation isn't needed for this survey since you likely want to target all users who cancel their trial. You can use a specific user action, like clicking **Cancel Trial**, to show the survey only to users trying your product.
|
Pre-segmentation isn't needed for this survey since you likely want to target all users who cancel their trial. You can use a specific user action, like clicking **Cancel Trial**, to show the survey only to users trying your product.
|
||||||
|
|
||||||
@@ -62,13 +56,13 @@ How you trigger your survey depends on your product. There are two options:
|
|||||||
|
|
||||||
- **Trigger by Page view:** If you have a page like `/trial-cancelled` for users who cancel their trial subscription, create a user action with the type "Page View." Select "Limit to specific pages" and apply URL filters with these settings:
|
- **Trigger by Page view:** If you have a page like `/trial-cancelled` for users who cancel their trial subscription, create a user action with the type "Page View." Select "Limit to specific pages" and apply URL filters with these settings:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Whenever a user visits this page, the survey will be displayed ✅
|
Whenever a user visits this page, the survey will be displayed ✅
|
||||||
|
|
||||||
- **Trigger by Button Click:** In a different case, you have a “Cancel Trial" button in your app. You can setup a user Action with the `Inner Text`:
|
- **Trigger by Button Click:** In a different case, you have a “Cancel Trial" button in your app. You can setup a user Action with the `Inner Text`:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.
|
Please have a look at our complete [Actions manual](/xm-and-surveys/surveys/website-app-surveys/actions) if you have questions.
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user