Compare commits

..

1 Commits

Author SHA1 Message Date
Matti Nannt
66fd5a5d85 feat: add hub docs rewrites 2026-02-17 17:03:37 +01:00
201 changed files with 983 additions and 15222 deletions

View File

@@ -229,24 +229,5 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -6,9 +6,19 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push:
branches:
- main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs:
validate-translations:
@@ -23,38 +33,30 @@ jobs:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
run: pnpm run scan-translations
run: |
echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Skip (no translation-related changes)
if: steps.changes.outputs.translations != 'true'
run: echo "No translation-related files changed — skipping validation."
- name: Summary
if: success()
run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""

View File

@@ -1 +1,40 @@
pnpm lint-staged
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi

View File

@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns

View File

@@ -1,8 +0,0 @@
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <ChartsListPage environmentId={environmentId} />;
};
export default ChartsPage;

View File

@@ -1,7 +0,0 @@
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
const Page = (props: { params: Promise<{ environmentId: string; dashboardId: string }> }) => {
return <DashboardDetailPage params={props.params} />;
};
export default Page;

View File

@@ -1,8 +0,0 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <DashboardsListPage environmentId={environmentId} />;
};
export default DashboardsPage;

View File

@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return redirect(`/environments/${environmentId}/analysis/dashboards`);
};
export default AnalysisPage;

View File

@@ -2,7 +2,6 @@
import {
ArrowUpRightIcon,
ChartBar,
ChevronRightIcon,
Cog,
LogOutIcon,
@@ -115,13 +114,6 @@ export const MainNavigation = ({
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.analysis"),
href: `/environments/${environment.id}/analysis`,
icon: ChartBar,
isActive: pathname?.includes("/analysis"),
isHidden: false,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
@@ -196,7 +188,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,11 +49,8 @@ export const NotificationSwitch = ({
];
}
} else {
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -81,7 +78,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(

View File

@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
});
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.number().int().min(1).max(100),
offset: z.number().int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.schema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});

View File

@@ -1,125 +0,0 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean;
tab: "dropOffs" | "quotas" | "impressions" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean;
}
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val;
setTab(change);
};
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}>
<InteractiveCard
key="impressions"
tab="impressions"
<StatCard
label={t("environments.surveys.summary.impressions")}
percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/>
<StatCard
label={t("environments.surveys.summary.starts")}

View File

@@ -1,31 +1,21 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
@@ -61,76 +51,17 @@ export const SummaryPage = ({
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
@@ -190,18 +121,6 @@ export const SummaryPage = ({
setTab={setTab}
isQuotasAllowed={isQuotasAllowed}
/>
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5">

View File

@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps {
tab: "dropOffs" | "quotas" | "impressions";
tab: "dropOffs" | "quotas";
label: string;
percentage: number | null;
percentage: number;
value: React.ReactNode;
tooltipText: string;
isLoading: boolean;

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
},
]}
/>

View File

@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
<p className="mr-4 font-semibold text-slate-800">and</p>
<hr className="w-full text-slate-600" />
</div>
)}

View File

@@ -1,49 +1,12 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),

View File

@@ -20,10 +20,6 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -122,17 +118,6 @@ export const AddIntegrationModal = ({
resetForm();
}, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -144,7 +129,6 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
@@ -153,11 +137,13 @@ export const AddIntegrationModal = ({
});
if (!spreadsheetNameResponse?.data) {
showErrorMessageToast(spreadsheetNameResponse);
return;
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;
@@ -294,7 +280,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto 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">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -8,11 +8,9 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -37,23 +35,10 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
if (url) {
@@ -79,8 +64,6 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale}
/>
</>

View File

@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,19 +12,15 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale;
}
@@ -33,8 +29,6 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
@@ -74,17 +68,7 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
@@ -93,19 +77,6 @@ export const ManageIntegration = ({
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setSelectedIntegration(null);

View File

@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -257,16 +256,10 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.map((choice) => choice.imageUrl)
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.map((choice) => choice.imageUrl);
}
});

View File

@@ -18,7 +18,6 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -96,15 +95,12 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,

View File

@@ -1,6 +1,5 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -9,7 +8,7 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -43,39 +42,33 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
if (!code) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {
type: integrationType,
type: "googleSheets" as "googleSheets",
environment: environmentId,
config: {
key,
data: existingConfig?.data ?? [],
data: [],
email: userEmail,
},
};

View File

@@ -10,7 +10,6 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -178,14 +177,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
styling: environmentData.project.styling,
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
surveys: transformedSurveys,
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -44,10 +44,13 @@ const validateResponse = (
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);

View File

@@ -41,6 +41,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,10 +57,7 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
response: responses.successResponse(result.response),
};
} catch (error) {
return {
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -192,7 +190,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
response: responses.successResponse(updated),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,9 +54,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -157,6 +155,7 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
return {

View File

@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
response: responses.successResponse(surveysWithQuestions),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -112,6 +112,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -257,7 +257,6 @@ describe("endpoint-validator", () => {
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
@@ -313,19 +312,6 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).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", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
@@ -389,8 +375,6 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
@@ -406,7 +390,6 @@ describe("endpoint-validator", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
@@ -443,8 +426,6 @@ describe("endpoint-validator", () => {
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
@@ -459,8 +440,6 @@ describe("endpoint-validator", () => {
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
@@ -471,7 +450,6 @@ describe("endpoint-validator", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
@@ -480,14 +458,12 @@ describe("endpoint-validator", () => {
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
@@ -502,9 +478,6 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
@@ -556,7 +529,6 @@ describe("endpoint-validator", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
});
});
@@ -564,7 +536,6 @@ describe("endpoint-validator", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
@@ -572,12 +543,10 @@ describe("endpoint-validator", () => {
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
@@ -587,7 +556,6 @@ describe("endpoint-validator", () => {
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
@@ -623,7 +591,6 @@ describe("endpoint-validator", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,

View File

@@ -7,7 +7,6 @@ const PUBLIC_ROUTES = {
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
],
// API routes accessible from public domain

View File

@@ -106,7 +106,6 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
@@ -123,8 +122,6 @@ checksums:
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -154,7 +151,6 @@ checksums:
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/create: 757ccd28dd533ff3a933355273c1e32a
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -164,8 +160,6 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -197,16 +191,13 @@ checksums:
common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
common/error_loading_data: aaeffbfe4a2c2145442a57de524494be
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
@@ -218,9 +209,7 @@ checksums:
common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide: a6088b934651055bb27314d111be510b
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
common/import: 348b8ab981de5b7f1fca6d7302263bbd
@@ -238,7 +227,6 @@ checksums:
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
common/last_name: 2c9a7de7738ca007ba9023c385149c26
common/learn_more: e598091d132f890c37a6d4ed94f6d794
common/license_expired: 7af13535e320e4197989472c01387d2c
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
@@ -288,7 +276,6 @@ checksums:
common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
common/optional: 396fb9a0472daf401c392bdc3e248943
@@ -414,7 +401,6 @@ checksums:
common/top_right: 241f95c923846911aaf13af6109333e5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
@@ -431,7 +417,6 @@ checksums:
common/variables: ffd3eec5497af36d7b4e4185bad1313a
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
common/view: 36a9b5e3dc153c036d320460d72a03c3
common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c
common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9
common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
@@ -589,170 +574,6 @@ checksums:
environments/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
environments/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
environments/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
environments/analysis/charts/OR: 0208d355f231c386b19390f0bea41b95
environments/analysis/charts/add_chart_to_dashboard: c2a517ada86cdda60e49bec655ca9a6d
environments/analysis/charts/add_chart_to_dashboard_description: 08980a1849757e9aec21fca5881c6be4
environments/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
environments/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
environments/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
environments/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
environments/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
environments/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
environments/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
environments/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
environments/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
environments/analysis/charts/chart_builder_choose_chart_type: 1376de2dcafac573a2df9e4c007b0ec8
environments/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
environments/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
environments/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
environments/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
environments/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
environments/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
environments/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc
environments/analysis/charts/chart_name_placeholder: 7370d4f88f27aea337ba1c36465c3f8b
environments/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413
environments/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb
environments/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61
environments/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
environments/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
environments/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
environments/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
environments/analysis/charts/chart_type_not_supported: 7ff0afc493b36f3f3c12c7c230df9757
environments/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
environments/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357
environments/analysis/charts/configure_description: 2939321f78e4ffbc57b4259ddaddb09d
environments/analysis/charts/configure_title: ab767b11da1d386b98b3f634f79d3abe
environments/analysis/charts/configure_type_label: cd13e4b37fb2021af55903e7690a9856
environments/analysis/charts/contains: 06dd606c0a8f81f9a03b414e9ae89440
environments/analysis/charts/create_chart: ca7fdcc964e01f42ea9709924221edba
environments/analysis/charts/create_chart_description: b9680bd8905dea180fa59a86f61de34e
environments/analysis/charts/custom_range: 99f4d72b64621406acc162cceeb1fed7
environments/analysis/charts/dashboard: c9380ea68c8c76ea451bd9613329a07c
environments/analysis/charts/dashboard_select_placeholder: 9b875f2f10050d650ae63be53fe0d4e8
environments/analysis/charts/data_label: b7b46ab6ce9606032c8f81f6f6afbb9b
environments/analysis/charts/date_preset_last_30_days: a738894cfc5e592052f1e16787744568
environments/analysis/charts/date_preset_last_7_days: 3631df3109bfecfe358ba15dcf8bd6f5
environments/analysis/charts/date_preset_last_month: 848086395b28875c050d56e3933dae61
environments/analysis/charts/date_preset_this_month: 50845a38865204a97773c44dcd2ebb90
environments/analysis/charts/date_preset_this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/analysis/charts/date_preset_this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/analysis/charts/date_preset_today: 142173f9752e18e92109623a3ee68cad
environments/analysis/charts/date_preset_yesterday: eeb58908e68ff96c1b7e8f90e389afb7
environments/analysis/charts/date_range: 9b3aa5954144de586931f60ef9594e99
environments/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
environments/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
environments/analysis/charts/dimensions_toggle_description: 50d1c6e73d2cb7320c9e29cec11b4c76
environments/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
environments/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
environments/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8
environments/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
environments/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50
environments/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d
environments/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a
environments/analysis/charts/failed_to_add_chart_to_dashboard: 355a5606399edcbb3e6d0ba0b66f12a6
environments/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
environments/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
environments/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
environments/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
environments/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
environments/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
environments/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
environments/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
environments/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
environments/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
environments/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
environments/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
environments/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
environments/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
environments/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
environments/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
environments/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
environments/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
environments/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
environments/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
environments/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
environments/analysis/charts/filters: acf5accc113ff3c1992688058576732c
environments/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
environments/analysis/charts/generate_chart: 8b0ca95be31a8401b13eafa26cf01d31
environments/analysis/charts/granularity: 9eb09aef092e7803ce4acb7965cbbaa9
environments/analysis/charts/granularity_day: 47648cd60fc313bc3f05b70357a1d675
environments/analysis/charts/granularity_hour: ec3113f22fc51d01f0c615c5496f8f87
environments/analysis/charts/granularity_month: ae7bef950efc406ff0980affabc1a64c
environments/analysis/charts/granularity_quarter: 7a68ec90d7c90b92b7bb873834a00381
environments/analysis/charts/granularity_week: 436fdd694160827dd6ea4644cdd0a8f8
environments/analysis/charts/granularity_year: ed86f5f60583f9d8ffdbeed306aa0ec7
environments/analysis/charts/greater_than: a4c18b3b45fcaf7c83bf489cf2b506d4
environments/analysis/charts/greater_than_or_equal: d453e26d136847560148168797fece51
environments/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
environments/analysis/charts/group_by_description: c54368c05d71c1bdbd2a5c0629c1dc03
environments/analysis/charts/guide_button: 3c5e2e28f6d9f1a644759c9c19878539
environments/analysis/charts/guide_chart_type: 1fd60a98a0b5a7f54521e7671772e4a3
environments/analysis/charts/guide_chart_type_desc: 4630292b6955c930a9c6d4169bf656a2
environments/analysis/charts/guide_dimensions: 746caf6f43a222f3ffdaae578323d36a
environments/analysis/charts/guide_dimensions_desc: 909a149ef47c2f811d65f437b34ea719
environments/analysis/charts/guide_filters: acf5accc113ff3c1992688058576732c
environments/analysis/charts/guide_filters_desc: 0c18f563b477cd9a0f2309c31174cd93
environments/analysis/charts/guide_measures: 2e4d2701ebb196e5a9122f03727e93d7
environments/analysis/charts/guide_measures_predefined: cb8b80a960a466aca9ad75d3e870f74b
environments/analysis/charts/guide_quick_ref: 6538588cf9323d85bf11b794448d846d
environments/analysis/charts/guide_term_dimension: 64bd5923ae7aa2cdbf967aca977e4945
environments/analysis/charts/guide_term_filter: c8dc27ccd08e7ec1e268dfd286660e79
environments/analysis/charts/guide_term_measure: ca94a6e1afcb8a7ddb0d79039ecd3bfb
environments/analysis/charts/guide_term_time: ddd5b6a7a0f8525b0fe2b7c3431319f2
environments/analysis/charts/guide_time_dimension: c6fed7f718296b2f23230a918bfe6196
environments/analysis/charts/guide_time_dimension_desc: 4565fd19f4346e0f0d52f79640d7d749
environments/analysis/charts/guide_title: e887f73e68a76c88fdef859bafc866a1
environments/analysis/charts/is_not_set: 906801489132487ef457652af4835142
environments/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
environments/analysis/charts/less_than: fb41255dd44bb6de78617b078610c91b
environments/analysis/charts/less_than_or_equal: da4a2816aadf788d33efcdcc3c61802e
environments/analysis/charts/measures: b1e6cf0f356dda0052c4fef4ad4957a2
environments/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
environments/analysis/charts/no_dashboards_available: f88389b6c5278cfc4d5b360031205dfe
environments/analysis/charts/no_dashboards_create_first: 28ded0d72247191eb23f6f77925df539
environments/analysis/charts/no_data_available: fe1d34a45e22b5611d255b84b2d67232
environments/analysis/charts/no_data_returned: 683acf7b4f3b32aa85fa26f1bb948d4f
environments/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046
environments/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d
environments/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9
environments/analysis/charts/not_contains: 5894f5474271b8902d7892e43500d227
environments/analysis/charts/not_equals: 427715f1ea349965c36f5c628784eb08
environments/analysis/charts/open_chart: bc3bed1517ad63c1bcccfbbc430ab333
environments/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
environments/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
environments/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
environments/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
environments/analysis/charts/please_select_at_least_one_measure: d4163ede267f71ee65945f453e14ff7b
environments/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
environments/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
environments/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
environments/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
environments/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
environments/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
environments/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
environments/analysis/charts/select_dimensions: 6d0d038d027ef9e641bf9b7700edac9f
environments/analysis/charts/select_field: 45665a44f7d5707506364f17f28db3bf
environments/analysis/charts/select_measures: c9f101aeb53bf0d4abdd652aaf60a1bf
environments/analysis/charts/select_preset: e68bad9a209a6ca35c62184f1f1d829c
environments/analysis/charts/showing_first_n_of: 4dec3215fd3150a16ad5c72f17ae02bc
environments/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
environments/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
environments/analysis/charts/time_dimension_toggle_description: 28da119989e3c73b098c650fe279ee4a
environments/analysis/dashboards/create_dashboard: 9396aec1ea4a9b05ada94483655d1373
environments/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
environments/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
environments/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
environments/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
environments/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
environments/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
environments/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
environments/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
environments/analysis/dashboards/description_optional: d5519551a79f18fc414dc127b773485f
environments/analysis/dashboards/description_placeholder: 90a599e6b1695e2b026fb1300d1d5903
environments/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
environments/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
environments/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
environments/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
environments/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
environments/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
environments/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
@@ -788,6 +609,7 @@ checksums:
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
@@ -798,7 +620,6 @@ checksums:
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
environments/contacts/displays: fcc4527002bd045021882be463b8ac72
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
@@ -810,7 +631,6 @@ checksums:
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
environments/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
@@ -825,8 +645,6 @@ checksums:
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
@@ -893,12 +711,7 @@ checksums:
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
environments/integrations/google_sheets/spreadsheet_permission_error: 94f0007a187d3b9a7ab8200fe26aad20
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
@@ -2033,7 +1846,6 @@ checksums:
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
environments/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
@@ -2074,7 +1886,6 @@ checksums:
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
environments/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6

View File

@@ -1,10 +1,9 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
@@ -24,12 +23,13 @@ export const getDisplayCountBySurveyId = reactCache(
const displayCount = await prisma.display.count({
where: {
surveyId: surveyId,
...(filters?.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
...(filters &&
filters.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
},
});
return displayCount;
@@ -42,97 +42,6 @@ export const getDisplayCountBySurveyId = reactCache(
}
);
export const getDisplaysByContactId = reactCache(
async (contactId: string): Promise<Pick<TDisplay, "id" | "createdAt" | "surveyId">[]> => {
validateInputs([contactId, ZId]);
try {
const displays = await prisma.display.findMany({
where: { contactId },
select: {
id: true,
createdAt: true,
surveyId: true,
},
orderBy: { createdAt: "desc" },
});
return displays;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getDisplaysBySurveyIdWithContact = reactCache(
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
validateInputs(
[surveyId, ZId],
[limit, z.number().int().min(1).optional()],
[offset, z.number().int().nonnegative().optional()]
);
try {
const displays = await prisma.display.findMany({
where: {
surveyId,
contactId: { not: null },
},
select: {
id: true,
createdAt: true,
surveyId: true,
contact: {
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: { in: ["email", "userId"] },
},
},
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return displays.map((display) => ({
id: display.id,
createdAt: display.createdAt,
surveyId: display.surveyId,
contact: display.contact
? {
id: display.contact.id,
attributes: display.contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
),
}
: null,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {

View File

@@ -1,219 +0,0 @@
import { mockDisplayId, mockSurveyId } from "./__mocks__/data.mock";
import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockDisplaysForContact = [
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
},
];
const mockDisplaysWithContact = [
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: {
id: mockContactId,
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "userId" }, value: "user-123" },
],
},
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
contact: {
id: "clqnj99r9000008lebgf8734k",
attributes: [{ attributeKey: { key: "userId" }, value: "user-456" }],
},
},
];
describe("getDisplaysByContactId", () => {
describe("Happy Path", () => {
test("returns displays for a contact ordered by createdAt desc", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysForContact as any);
const result = await getDisplaysByContactId(mockContactId);
expect(result).toEqual(mockDisplaysForContact);
expect(prisma.display.findMany).toHaveBeenCalledWith({
where: { contactId: mockContactId },
select: {
id: true,
createdAt: true,
surveyId: true,
},
orderBy: { createdAt: "desc" },
});
});
test("returns empty array when contact has no displays", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
const result = await getDisplaysByContactId(mockContactId);
expect(result).toEqual([]);
});
});
describe("Sad Path", () => {
test("throws a ValidationError if the contactId is invalid", async () => {
await expect(getDisplaysByContactId("not-a-cuid")).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(DatabaseError);
});
test("throws generic Error for other exceptions", async () => {
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(Error);
});
});
});
describe("getDisplaysBySurveyIdWithContact", () => {
describe("Happy Path", () => {
test("returns displays with contact attributes transformed", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysWithContact as any);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
expect(result).toEqual([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: {
id: mockContactId,
attributes: { email: "test@example.com", userId: "user-123" },
},
},
{
id: "clqkr5smu000208jy50v6g5k5",
createdAt: new Date("2024-01-14T10:00:00Z"),
surveyId: "clqkr8dlv000308jybb08evgs",
contact: {
id: "clqnj99r9000008lebgf8734k",
attributes: { userId: "user-456" },
},
},
]);
});
test("calls prisma with correct where clause and pagination", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
expect(prisma.display.findMany).toHaveBeenCalledWith({
where: {
surveyId: mockSurveyId,
contactId: { not: null },
},
select: {
id: true,
createdAt: true,
surveyId: true,
contact: {
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: { in: ["email", "userId"] },
},
},
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
take: 15,
skip: 0,
});
});
test("returns empty array when no displays found", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
expect(result).toEqual([]);
});
test("handles display with null contact", async () => {
vi.mocked(prisma.display.findMany).mockResolvedValue([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: null,
},
] as any);
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
expect(result).toEqual([
{
id: mockDisplayId,
createdAt: new Date("2024-01-15T10:00:00Z"),
surveyId: mockSurveyId,
contact: null,
},
]);
});
});
describe("Sad Path", () => {
test("throws a ValidationError if the surveyId is invalid", async () => {
await expect(getDisplaysBySurveyIdWithContact("not-a-cuid")).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(DatabaseError);
});
test("throws generic Error for other exceptions", async () => {
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(Error);
});
});
});

View File

@@ -1,6 +0,0 @@
/**
* Error codes returned by Google Sheets integration.
* Use these constants when comparing error responses to avoid typos and enable reuse.
*/
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";

View File

@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZString } from "@formbricks/types/common";
import {
AuthenticationError,
DatabaseError,
OperationNotAllowedError,
UnknownError,
} from "@formbricks/types/errors";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
@@ -16,12 +11,8 @@ import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
GOOGLE_SHEET_MESSAGE_LIMIT,
} from "@/lib/constants";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { truncateText } from "../utils/strings";
import { validateInputs } from "../utils/validate";
@@ -90,17 +81,6 @@ export const writeData = async (
}
};
export const validateGoogleSheetsConnection = async (
googleSheetIntegrationData: TIntegrationGoogleSheets
): Promise<void> => {
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
const integrationData = structuredClone(googleSheetIntegrationData);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
await authorize(integrationData);
};
export const getSpreadsheetNameById = async (
googleSheetIntegrationData: TIntegrationGoogleSheets,
spreadsheetId: string
@@ -114,17 +94,7 @@ export const getSpreadsheetNameById = async (
return new Promise((resolve, reject) => {
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
if (err) {
const msg = err.message?.toLowerCase() ?? "";
const isPermissionError =
msg.includes("permission") ||
msg.includes("caller does not have") ||
msg.includes("insufficient permission") ||
msg.includes("access denied");
if (isPermissionError) {
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
} else {
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
}
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
return;
}
const spreadsheetTitle = response.data.properties.title;
@@ -139,70 +109,26 @@ export const getSpreadsheetNameById = async (
}
};
const isInvalidGrantError = (error: unknown): boolean => {
const err = error as { message?: string; response?: { data?: { error?: string } } };
return (
typeof err?.message === "string" &&
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
);
};
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
/**
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
* Returns true if token is valid, false if invalid/revoked.
*/
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
try {
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
return res.ok;
} catch {
return false;
}
};
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const key = googleSheetIntegrationData.config.key;
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
oAuth2Client.setCredentials({
refresh_token,
});
const { credentials } = await oAuth2Client.refreshAccessToken();
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: credentials,
},
});
const hasStoredCredentials =
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
oAuth2Client.setCredentials(credentials);
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
oAuth2Client.setCredentials(key);
return oAuth2Client;
}
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
try {
const { credentials } = await oAuth2Client.refreshAccessToken();
const mergedCredentials = {
...credentials,
refresh_token: credentials.refresh_token ?? key.refresh_token,
};
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
type: "googleSheets",
config: {
data: googleSheetIntegrationData.config?.data ?? [],
email: googleSheetIntegrationData.config?.email ?? "",
key: mergedCredentials,
},
});
oAuth2Client.setCredentials(mergedCredentials);
return oAuth2Client;
} catch (error) {
if (isInvalidGrantError(error)) {
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
}
throw error;
}
return oAuth2Client;
};

View File

@@ -22,7 +22,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -409,10 +408,9 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
resolvedResponses,
responses,
elements,
userAttributes,
hiddenFields,

View File

@@ -22,9 +22,6 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {

View File

@@ -133,7 +133,6 @@
"allow": "erlauben",
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
"analysis": "Analyse",
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
@@ -150,8 +149,6 @@
"bottom_right": "Unten rechts",
"cancel": "Abbrechen",
"centered_modal": "Zentriertes Modalfenster",
"chart": "Diagramm",
"charts": "Diagramme",
"choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
"create": "Erstellen",
"create_new_organization": "Neue Organisation erstellen",
"create_segment": "Segment erstellen",
"create_survey": "Umfrage erstellen",
@@ -191,8 +187,6 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "Tage",
"default": "Standard",
@@ -224,16 +218,13 @@
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
"error_loading_data": "Fehler beim Laden der Daten",
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern",
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"filter": "Filter",
"finish": "Fertigstellen",
"first_name": "Vorname",
"follow_these": "Folge diesen",
"formbricks_version": "Formbricks Version",
"full_name": "Name",
@@ -245,9 +236,7 @@
"hidden": "Versteckt",
"hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder",
"hide": "Ausblenden",
"hide_column": "Spalte ausblenden",
"id": "ID",
"image": "Bild",
"images": "Bilder",
"import": "Importieren",
@@ -265,7 +254,6 @@
"key": "Schlüssel",
"label": "Bezeichnung",
"language": "Sprache",
"last_name": "Nachname",
"learn_more": "Mehr erfahren",
"license_expired": "License Expired",
"light_overlay": "Helle Überlagerung",
@@ -315,7 +303,6 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"open_options": "Optionen öffnen",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"optional": "Optional",
@@ -441,7 +428,6 @@
"top_right": "Oben rechts",
"try_again": "Versuch's nochmal",
"type": "Typ",
"unknown_survey": "Unbekannte Umfrage",
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
"update": "Aktualisierung",
"updated": "Aktualisiert",
@@ -458,7 +444,6 @@
"variables": "Variablen",
"verified_email": "Verifizierte E-Mail",
"video": "Video",
"view": "Ansehen",
"warning": "Warnung",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Wir konnten Ihre Lizenz nicht überprüfen, da der Lizenzserver nicht erreichbar ist.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
},
"analysis": {
"charts": {
"OR": "ODER",
"add_chart_to_dashboard": "Diagramm zum Dashboard hinzufügen",
"add_chart_to_dashboard_description": "Wähle ein Dashboard aus, um dieses Diagramm hinzuzufügen. Das Diagramm wird automatisch gespeichert.",
"add_filter": "Filter hinzufügen",
"add_to_dashboard": "Zum Dashboard hinzufügen",
"advanced_chart_builder_config_prompt": "Konfiguriere dein Diagramm und klicke auf \"Abfrage ausführen\", um eine Vorschau zu sehen",
"ai_query_placeholder": "z.B. Wie viele Nutzer haben sich letzte Woche angemeldet?",
"ai_query_section_description": "Beschreibe, was du sehen möchtest, und lass die KI das Diagramm erstellen.",
"ai_query_section_title": "Frag deine Daten",
"apply_changes": "Änderungen übernehmen",
"chart": "Diagramm",
"chart_added_to_dashboard": "Diagramm zum Dashboard hinzugefügt!",
"chart_builder_choose_chart_type": "Diagrammtyp auswählen",
"chart_data": "Diagrammdaten",
"chart_data_tab": "Daten",
"chart_deleted_successfully": "Diagramm erfolgreich gelöscht",
"chart_deletion_error": "Diagramm konnte nicht gelöscht werden",
"chart_duplicated_successfully": "Diagramm erfolgreich dupliziert",
"chart_duplication_error": "Diagramm konnte nicht dupliziert werden",
"chart_name": "Diagrammname",
"chart_name_placeholder": "Diagrammname",
"chart_preview": "Diagrammvorschau",
"chart_render_error": "Beim Rendern dieses Diagramms ist etwas schiefgelaufen.",
"chart_saved_successfully": "Diagramm erfolgreich gespeichert!",
"chart_type_area": "Flächendiagramm",
"chart_type_bar": "Balkendiagramm",
"chart_type_big_number": "Große Zahl",
"chart_type_line": "Liniendiagramm",
"chart_type_not_supported": "Diagrammtyp \"{{chartType}}\" wird noch nicht unterstützt",
"chart_type_pie": "Kreisdiagramm",
"chart_updated_successfully": "Diagramm erfolgreich aktualisiert!",
"configure_description": "Ändere den Diagrammtyp und andere Einstellungen für diese Visualisierung.",
"configure_title": "Diagramm konfigurieren",
"configure_type_label": "Diagrammtyp",
"contains": "enthält",
"create_chart": "Diagramm erstellen",
"create_chart_description": "Nutze KI, um ein Diagramm zu generieren, oder erstelle es manuell.",
"custom_range": "Benutzerdefinierter Bereich",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "Wähle ein Dashboard aus",
"data_label": "Daten",
"date_preset_last_30_days": "Letzte 30 Tage",
"date_preset_last_7_days": "Letzte 7 Tage",
"date_preset_last_month": "Letzter Monat",
"date_preset_this_month": "Dieser Monat",
"date_preset_this_quarter": "Dieses Quartal",
"date_preset_this_year": "Dieses Jahr",
"date_preset_today": "Heute",
"date_preset_yesterday": "Gestern",
"date_range": "Datumsbereich",
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
"dimensions": "Dimensionen",
"dimensions_toggle_description": "Gruppiere Daten nach Kategorien. Die Reihenfolge ist bei mehrdimensionalen Diagrammen wichtig.",
"edit_chart_description": "Sieh dir deine Diagrammkonfiguration an und bearbeite sie.",
"edit_chart_title": "Diagramm bearbeiten",
"enable_time_dimension": "Zeitdimension aktivieren",
"end_date": "Enddatum",
"enter_a_name_for_your_chart": "Gib einen Namen für dein Diagramm ein, um es zu speichern.",
"enter_value": "Wert eingeben",
"equals": "gleich",
"failed_to_add_chart_to_dashboard": "Diagramm konnte nicht zum Dashboard hinzugefügt werden",
"failed_to_execute_query": "Abfrage konnte nicht ausgeführt werden",
"failed_to_load_chart": "Diagramm konnte nicht geladen werden",
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
"field": "Feld",
"field_label_average_score": "Durchschnittliche Bewertung",
"field_label_collected_at": "Erfasst am",
"field_label_count": "Anzahl",
"field_label_detractor_count": "Anzahl Kritiker",
"field_label_emotion": "Emotion",
"field_label_field_type": "Feldtyp",
"field_label_nps_score": "NPS-Score",
"field_label_nps_value": "NPS-Wert",
"field_label_passive_count": "Anzahl Passive",
"field_label_promoter_count": "Anzahl Promoter",
"field_label_response_id": "Antwort-ID",
"field_label_sentiment": "Stimmung",
"field_label_source_name": "Quellenname",
"field_label_source_type": "Quellentyp",
"field_label_topic": "Thema",
"field_label_user_identifier": "Benutzerkennung",
"filters": "Filter",
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
"generate_chart": "Diagramm generieren",
"granularity": "Granularität",
"granularity_day": "Tag",
"granularity_hour": "Stunde",
"granularity_month": "Monat",
"granularity_quarter": "Quartal",
"granularity_week": "Woche",
"granularity_year": "Jahr",
"greater_than": "größer als",
"greater_than_or_equal": "größer als oder gleich",
"group_by": "Gruppieren nach",
"group_by_description": "Wähle Dimensionen aus, um deine Daten aufzuschlüsseln. Die Reihenfolge ist bei mehrdimensionalen Diagrammen wichtig.",
"guide_button": "Feldleitfaden anzeigen",
"guide_chart_type": "Diagrammtyp",
"guide_chart_type_desc": "Wie die Daten visualisiert werden: Fläche, Balken, Linie, Kreis oder große Zahl. Wähle basierend darauf, was du zeigen möchtest (Trends, Vergleiche, Teile eines Ganzen usw.).",
"guide_dimensions": "Dimensionen (Gruppieren nach)",
"guide_dimensions_desc": "Wie du die Daten aufteilst oder gruppierst. Jede Dimension wird zu einer Kategorie im Diagramm (z. B. Stimmung, Quellentyp, Umfragename, Kanal, Thema). Die Reihenfolge ist bei mehrdimensionalen Diagrammen wichtig.",
"guide_filters": "Filter",
"guide_filters_desc": "Bedingungen, die einschränken, welche Daten einbezogen werden. Jeder Filter hat ein Feld, einen Operator (gleich, enthält, größer als usw.) und Werte. Und = alle müssen übereinstimmen; Oder = beliebige können übereinstimmen.",
"guide_measures": "Kennzahlen (was du zählst oder aggregierst)",
"guide_measures_predefined": "Vordefinierte Kennzahlen sind vorgefertigte Metriken aus deinen Feedback-Daten: Count (Gesamtantworten), Promoter/Detractor/Passive Count (NPS-Segmente), NPS Score, Average Score, Completion Rate.",
"guide_quick_ref": "Schnellreferenz",
"guide_term_dimension": "Kategorisches Feld, das zum Gruppieren oder Aufteilen von Daten verwendet wird",
"guide_term_filter": "Bedingung, die einschränkt, welche Zeilen einbezogen werden",
"guide_term_measure": "Numerischer Wert, den du aggregierst (count, sum, avg usw.)",
"guide_term_time": "Zeitbasierte Gruppierung mit Granularität und Datumsbereich",
"guide_time_dimension": "Zeitdimension",
"guide_time_dimension_desc": "Zeitbasierte Gruppierung: Wähle ein Zeitfeld (normalerweise Erfasst am), Granularität (Stunde, Tag, Woche, Monat usw.) und Datumsbereich (Voreinstellung oder benutzerdefiniert). Verwende dies für Trends im Zeitverlauf.",
"guide_title": "Diagramm-Builder Feldleitfaden",
"is_not_set": "ist nicht festgelegt",
"is_set": "ist festgelegt",
"less_than": "kleiner als",
"less_than_or_equal": "kleiner oder gleich",
"measures": "Kennzahlen",
"no_charts_found": "Keine Diagramme gefunden.",
"no_dashboards_available": "Keine Dashboards verfügbar",
"no_dashboards_create_first": "Erstelle zuerst ein Dashboard, um Diagramme hinzuzufügen.",
"no_data_available": "Keine Daten verfügbar",
"no_data_returned": "Keine Daten von der Abfrage zurückgegeben",
"no_data_returned_for_chart": "Keine Daten für Diagramm zurückgegeben",
"no_grouping": "Keine (nur Filter)",
"no_valid_data_to_display": "Keine gültigen Daten zur Anzeige",
"not_contains": "enthält nicht",
"not_equals": "ist nicht gleich",
"open_chart": "Diagramm {{name}} öffnen",
"open_options": "Diagrammoptionen öffnen",
"or_filter_logic": "ODER",
"original": "Original",
"please_enter_chart_name": "Bitte gib einen Diagrammnamen ein",
"please_select_at_least_one_measure": "Bitte wähle mindestens eine Kennzahl aus",
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
"predefined_measures": "Vordefinierte Kennzahlen",
"preset": "Vorlage",
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
"save_chart": "Diagramm speichern",
"save_chart_dialog_title": "Diagramm speichern",
"select_dimensions": "Dimensionen auswählen...",
"select_field": "Feld auswählen",
"select_measures": "Metriken auswählen...",
"select_preset": "Vorlage auswählen",
"showing_first_n_of": "Zeige die ersten {{n}} von {{count}} Zeilen",
"start_date": "Startdatum",
"time_dimension": "Zeitdimension",
"time_dimension_toggle_description": "Füge zeitbasierte Gruppierung hinzu, um Trends im Zeitverlauf zu sehen."
},
"dashboards": {
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
"create_failed": "Dashboard konnte nicht erstellt werden",
"create_success": "Dashboard erfolgreich erstellt!",
"dashboard_name": "Dashboard-Name",
"dashboard_name_placeholder": "Mein Dashboard",
"delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_failed": "Dashboard konnte nicht gelöscht werden",
"delete_success": "Dashboard erfolgreich gelöscht",
"description_optional": "Beschreibung (optional)",
"description_placeholder": "Dashboard-Beschreibung",
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
"duplicate_success": "Dashboard erfolgreich dupliziert!",
"no_dashboards_found": "Keine Dashboards gefunden.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
}
},
"connect": {
"congrats": "Glückwunsch!",
"connection_successful_message": "Gut gemacht! Wir sind verbunden.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen",
"create_key": "Schlüssel erstellen",
"create_new_attribute": "Neues Attribut erstellen",
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
"custom_attributes": "Benutzerdefinierte Attribute",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {Dadurch wird das ausgewählte Attribut gelöscht. Alle mit diesem Attribut verknüpften Kontaktdaten gehen verloren.} other {Dadurch werden die ausgewählten Attribute gelöscht. Alle mit diesen Attributen verknüpften Kontaktdaten gehen verloren.}}",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.} other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesen Kontakten verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn diesen Kontakten Antworten haben, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
"displays": "Anzeigen",
"edit_attribute": "Attribut bearbeiten",
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
"edit_attribute_values": "Attribute bearbeiten",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
"no_activity_yet": "Noch keine Aktivität",
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
"no_published_surveys": "Keine veröffentlichten Umfragen",
"no_responses_found": "Keine Antworten gefunden",
@@ -867,8 +681,6 @@
"select_a_survey": "Wähle eine Umfrage aus",
"select_attribute": "Attribut auswählen",
"select_attribute_key": "Attributschlüssel auswählen",
"survey_viewed": "Umfrage angesehen",
"survey_viewed_at": "Angesehen am",
"system_attributes": "Systemattribute",
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
@@ -940,12 +752,7 @@
"link_google_sheet": "Tabelle verlinken",
"link_new_sheet": "Neues Blatt verknüpfen",
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Google Sheets-Verbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
"spreadsheet_permission_error": "Du hast keine Berechtigung, auf diese Tabelle zuzugreifen. Bitte stelle sicher, dass die Tabelle mit deinem Google-Konto geteilt ist und du Schreibzugriff auf die Tabelle hast.",
"spreadsheet_url": "Tabellen-URL",
"token_expired_error": "Das Google Sheets-Aktualisierungstoken ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut."
"spreadsheet_url": "Tabellen-URL"
},
"include_created_at": "Erstellungsdatum einbeziehen",
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
"generating_qr_code": "QR-Code wird generiert",
"impressions": "Eindrücke",
"impressions_identified_only": "Zeigt nur Impressionen von identifizierten Kontakten",
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
"in_app": {
"connection_description": "Die Umfrage wird den Nutzern Ihrer Website angezeigt, die den unten aufgeführten Kriterien entsprechen",
@@ -2183,7 +1989,6 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"limit": "Limit",
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",

View File

@@ -122,8 +122,6 @@
"activity": "Activity",
"add": "Add",
"add_action": "Add action",
"add_charts": "Add charts",
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
@@ -135,7 +133,6 @@
"allow": "Allow",
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
"analysis": "Analysis",
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
@@ -152,8 +149,6 @@
"bottom_right": "Bottom Right",
"cancel": "Cancel",
"centered_modal": "Centered Modal",
"chart": "Chart",
"charts": "Charts",
"choices": "Choices",
"choose_environment": "Choose environment",
"choose_organization": "Choose organization",
@@ -183,8 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
"create": "Create",
"create_new_chart": "Create new chart",
"create_new_organization": "Create new organization",
"create_segment": "Create segment",
"create_survey": "Create survey",
@@ -194,8 +187,6 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Date",
"days": "days",
"default": "Default",
@@ -227,17 +218,13 @@
"error": "Error",
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
"error_component_title": "Error loading resources",
"error_loading_data": "Error loading data",
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"feature_coming_soon": "Feature coming soon",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
"follow_these": "Follow these",
"formbricks_version": "Formbricks Version",
"full_name": "Full name",
@@ -249,9 +236,7 @@
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide": "Hide",
"hide_column": "Hide column",
"id": "ID",
"image": "Image",
"images": "Images",
"import": "Import",
@@ -269,7 +254,6 @@
"key": "Key",
"label": "Label",
"language": "Language",
"last_name": "Last Name",
"learn_more": "Learn more",
"license_expired": "License Expired",
"light_overlay": "Light overlay",
@@ -293,7 +277,6 @@
"mobile_overlay_surveys_look_good": "Do not worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
"months": "months",
"more_options": "More options",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
@@ -302,7 +285,6 @@
"new_version_available": "Formbricks {version} is here. Upgrade now!",
"next": "Next",
"no_background_image_found": "No background image found.",
"no_changes": "No changes",
"no_code": "No code",
"no_files_uploaded": "No files were uploaded",
"no_overlay": "No overlay",
@@ -321,7 +303,6 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"open_options": "Open options",
"option_id": "Option ID",
"option_ids": "Option IDs",
"optional": "Optional",
@@ -364,7 +345,6 @@
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read docs",
"recipients": "Recipients",
"refresh": "Refresh",
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
@@ -385,7 +365,6 @@
"save_changes": "Save changes",
"saving": "Saving",
"search": "Search",
"search_charts": "Search charts...",
"security": "Security",
"segment": "Segment",
"segments": "Segments",
@@ -449,8 +428,6 @@
"top_right": "Top Right",
"try_again": "Try again",
"type": "Type",
"unknown_survey": "Unknown survey",
"unlock": "Unlock",
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
"update": "Update",
"updated": "Updated",
@@ -467,7 +444,6 @@
"variables": "Variables",
"verified_email": "Verified Email",
"video": "Video",
"view": "View",
"warning": "Warning",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
"webhook": "Webhook",
@@ -631,199 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
"your_survey_would_not_be_shown": "Your survey would not be shown."
},
"analysis": {
"charts": {
"OR": "OR",
"add_chart_to_dashboard": "Add Chart to Dashboard",
"add_chart_to_dashboard_description": "Select a dashboard to add this chart to. The chart will be saved automatically.",
"add_filter": "Add filter",
"add_to_dashboard": "Add to Dashboard",
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
"ai_query_placeholder": "e.g. How many users signed up last week?",
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
"ai_query_section_title": "Ask your data",
"and_filter_logic": "AND",
"apply_changes": "Apply Changes",
"chart": "Chart",
"chart_added_to_dashboard": "Chart added to dashboard!",
"chart_builder_choose_chart_type": "Choose chart type",
"chart_data": "Chart Data",
"chart_data_tab": "Data",
"chart_deleted_successfully": "Chart deleted successfully",
"chart_deletion_error": "Failed to delete chart",
"chart_duplicated_successfully": "Chart duplicated successfully",
"chart_duplication_error": "Failed to duplicate chart",
"chart_name": "Chart Name",
"chart_name_placeholder": "Chart name",
"chart_preview": "Chart Preview",
"chart_render_error": "Something went wrong while rendering this chart.",
"chart_saved_successfully": "Chart saved successfully!",
"chart_type_area": "Area Chart",
"chart_type_bar": "Bar Chart",
"chart_type_big_number": "Big Number",
"chart_type_line": "Line Chart",
"chart_type_not_supported": "Chart type \"{{chartType}}\" not yet supported",
"chart_type_pie": "Pie Chart",
"chart_updated_successfully": "Chart updated successfully!",
"configure_description": "Modify the chart type and other settings for this visualization.",
"configure_title": "Configure Chart",
"configure_type_label": "Chart Type",
"contains": "contains",
"create_chart": "Create chart",
"create_chart_description": "Use AI to generate a chart or build one manually.",
"create_chart_with_ai": "Create chart with AI",
"custom_range": "Custom Range",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "Select a dashboard",
"data_label": "Data",
"date_preset_last_30_days": "Last 30 days",
"date_preset_last_7_days": "Last 7 days",
"date_preset_last_month": "Last month",
"date_preset_this_month": "This month",
"date_preset_this_quarter": "This quarter",
"date_preset_this_year": "This year",
"date_preset_today": "Today",
"date_preset_yesterday": "Yesterday",
"date_range": "Date Range",
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
"dimensions": "Dimensions",
"dimensions_toggle_description": "Group data by sentiment, question type, and other dimensions.",
"edit_chart_description": "View and edit your chart configuration.",
"edit_chart_title": "Edit Chart",
"enable_time_dimension": "Enable Time Dimension",
"end_date": "End date",
"enter_a_name_for_your_chart": "Enter a name for your chart to save it.",
"enter_value": "Enter value",
"equals": "equals",
"failed_to_add_chart_to_dashboard": "Failed to add chart to dashboard",
"failed_to_execute_query": "Failed to execute query",
"failed_to_load_chart": "Failed to load chart",
"failed_to_load_chart_data": "Failed to load chart data",
"failed_to_save_chart": "Failed to save chart",
"field": "Field",
"field_label_average_score": "Average Score",
"field_label_collected_at": "Collected At",
"field_label_count": "Count",
"field_label_detractor_count": "Detractor Count",
"field_label_emotion": "Emotion",
"field_label_field_type": "Field Type",
"field_label_nps_score": "NPS Score",
"field_label_nps_value": "NPS Value",
"field_label_passive_count": "Passive Count",
"field_label_promoter_count": "Promoter Count",
"field_label_response_id": "Response ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Source Name",
"field_label_source_type": "Source Type",
"field_label_topic": "Topic",
"field_label_user_identifier": "User Identifier",
"filter_data": "Filter data",
"filters": "Filters",
"filters_toggle_description": "Only include data that meets the following conditions.",
"generate_chart": "Generate Chart",
"granularity": "Granularity",
"granularity_day": "Day",
"granularity_hour": "Hour",
"granularity_month": "Month",
"granularity_quarter": "Quarter",
"granularity_week": "Week",
"granularity_year": "Year",
"greater_than": "greater than",
"greater_than_or_equal": "greater than or equal",
"group_by": "Group By",
"group_by_description": "Break down your data by one or more dimensions. The order is important if you choose multiple dimensions.",
"group_data": "Group data",
"guide_button": "View field guide",
"guide_chart_type": "Chart type",
"guide_chart_type_desc": "How the data is visualized: Area, Bar, Line, Pie, or Big Number. Choose based on what you want to show (trends, comparisons, parts of a whole, etc.).",
"guide_dimensions": "Dimensions (Group By)",
"guide_dimensions_desc": "How you split or group the data. Each dimension becomes a category on the chart (e.g. Sentiment, Source Type, Survey Name, Channel, Topic). Order matters for multi-dimensional charts.",
"guide_filters": "Filters",
"guide_filters_desc": "Conditions that limit which data is included. Each filter has a field, operator (equals, contains, greater than, etc.), and values. And = all must match; Or = any can match.",
"guide_measures": "Measures (what you count or aggregate)",
"guide_measures_predefined": "Predefined measures are pre-built metrics from your feedback data: Count (total responses), Promoter/Detractor/Passive Count (NPS segments), NPS Score, Average Score, Completion Rate.",
"guide_quick_ref": "Quick reference",
"guide_term_dimension": "Categorical field used to group or split data",
"guide_term_filter": "Condition that limits which rows are included",
"guide_term_measure": "Numeric value you aggregate (count, sum, avg, etc.)",
"guide_term_time": "Time-based grouping with granularity and date range",
"guide_time_dimension": "Time dimension",
"guide_time_dimension_desc": "Time-based grouping: pick a time field (usually Collected At), granularity (Hour, Day, Week, Month, etc.), and date range (preset or custom). Use for trends over time.",
"guide_title": "Chart Builder Field Guide",
"is_not_set": "is not set",
"is_set": "is set",
"less_than": "less than",
"less_than_or_equal": "less than or equal",
"measures": "Measures",
"no_charts_found": "No charts found.",
"no_dashboards_available": "No dashboards available",
"no_dashboards_create_first": "Create a dashboard first to add charts to it.",
"no_data_available": "No data available",
"no_data_returned": "No data returned from query",
"no_data_returned_for_chart": "No data returned for chart",
"no_grouping": "None (filter only)",
"no_valid_data_to_display": "No valid data to display",
"not_contains": "not contains",
"not_equals": "not equals",
"open_chart": "Open chart {{name}}",
"open_options": "Open chart options",
"or_filter_logic": "OR",
"original": "Original",
"please_enter_chart_name": "Please enter a chart name",
"please_select_at_least_one_measure": "Please select at least one measure",
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_chart": "Save Chart",
"save_chart_dialog_title": "Save Chart",
"select_dimensions": "Select dimensions...",
"select_field": "Select field",
"select_measures": "Select measures...",
"select_preset": "Select preset",
"showing_first_n_of": "Showing first {{n}} of {{count}} rows",
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_title": "Add time-based grouping",
"time_dimension_toggle_description": "Monitor trends over time."
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
"charts_add_failed": "Failed to add charts to dashboard",
"charts_add_partial_failure": "Failed to add {count} chart(s)",
"charts_added_to_dashboard": "Charts added to dashboard",
"charts_load_failed": "Failed to load charts",
"create_dashboard": "Create dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
"create_failed": "Failed to create dashboard",
"create_success": "Dashboard created successfully!",
"dashboard": "Dashboard",
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
"dashboard_name": "Dashboard Name",
"dashboard_name_placeholder": "My dashboard",
"dashboard_name_required": "Dashboard name is required",
"dashboard_save_failed": "Failed to save dashboard",
"dashboard_saved": "Dashboard saved successfully",
"dashboard_update_failed": "Failed to update dashboard",
"delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
"delete_failed": "Failed to delete dashboard",
"delete_success": "Dashboard deleted successfully",
"description_optional": "Description (Optional)",
"description_placeholder": "Dashboard description",
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"no_charts_available_description": "There are no charts that can be added to this dashboard. Either no charts exist yet, or all existing charts have already been added. Go to the Charts page to create new charts.",
"no_charts_exist": "No charts exist yet. Create one first.",
"no_charts_to_add_message": "No charts to add to this dashboard.",
"no_dashboards_found": "No dashboards found.",
"no_data_description": "There is currently no information to display. Add charts to build your dashboard.",
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
"no_data_title": "No Data",
"please_enter_name": "Please enter a dashboard name",
"widget_layouts_save_failed": "Failed to update widget layouts"
}
},
"connect": {
"congrats": "Congrats!",
"connection_successful_message": "Well done! We are connected.",
@@ -862,6 +645,7 @@
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
"create_key": "Create Key",
"create_new_attribute": "Create new attribute",
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
"custom_attributes": "Custom Attributes",
@@ -872,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contacts 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 contacts data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
"displays": "Displays",
"edit_attribute": "Edit attribute",
"edit_attribute_description": "Update the label and description for this attribute.",
"edit_attribute_values": "Edit attributes",
@@ -884,7 +667,6 @@
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
"invalid_date_format": "Invalid date format. Please use a valid date.",
"invalid_number_format": "Invalid number format. Please enter a valid number.",
"no_activity_yet": "No activity yet",
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
"no_published_surveys": "No published surveys",
"no_responses_found": "No responses found",
@@ -899,8 +681,6 @@
"select_a_survey": "Select a survey",
"select_attribute": "Select Attribute",
"select_attribute_key": "Select attribute key",
"survey_viewed": "Survey viewed",
"survey_viewed_at": "Viewed At",
"system_attributes": "System Attributes",
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
"unlock_contacts_title": "Unlock contacts with a higher plan",
@@ -972,12 +752,7 @@
"link_google_sheet": "Link Google Sheet",
"link_new_sheet": "Link new Sheet",
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your Google Sheets connection has expired. Please reconnect to continue syncing responses. Your existing spreadsheet links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing spreadsheet links and data will be preserved.",
"spreadsheet_permission_error": "You don't have permission to access this spreadsheet. Please ensure the spreadsheet is shared with your Google account and you have write access to the spreadsheet.",
"spreadsheet_url": "Spreadsheet URL",
"token_expired_error": "Google Sheets refresh token has expired or been revoked. Please reconnect the integration."
"spreadsheet_url": "Spreadsheet URL"
},
"include_created_at": "Include Created At",
"include_hidden_fields": "Include Hidden Fields",
@@ -2172,7 +1947,6 @@
"filtered_responses_excel": "Filtered responses (Excel)",
"generating_qr_code": "Generating QR code",
"impressions": "Impressions",
"impressions_identified_only": "Only showing impressions from identified contacts",
"impressions_tooltip": "Number of times the survey has been viewed.",
"in_app": {
"connection_description": "The survey will be shown to users of your website, that match the criteria listed below",
@@ -2215,7 +1989,6 @@
"last_quarter": "Last quarter",
"last_year": "Last year",
"limit": "Limit",
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"other_values_found": "Other values found",
"overall": "Overall",

View File

@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
"analysis": "Análisis",
"and": "Y",
"and_response_limit_of": "y límite de respuesta de",
"anonymous": "Anónimo",
@@ -150,8 +149,6 @@
"bottom_right": "Inferior derecha",
"cancel": "Cancelar",
"centered_modal": "Modal centrado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Opciones",
"choose_environment": "Elegir entorno",
"choose_organization": "Elegir organización",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
"create": "Crear",
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
@@ -191,8 +187,6 @@
"created_by": "Creado por",
"customer_success": "Éxito del cliente",
"dark_overlay": "Superposición oscura",
"dashboard": "Panel de control",
"dashboards": "Paneles",
"date": "Fecha",
"days": "días",
"default": "Predeterminado",
@@ -224,16 +218,13 @@
"error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
"error_component_title": "Error al cargar recursos",
"error_loading_data": "Error al cargar los datos",
"error_rate_limit_description": "Número máximo de solicitudes alcanzado. Por favor, inténtalo de nuevo más tarde.",
"error_rate_limit_title": "Límite de frecuencia excedido",
"expand_rows": "Expandir filas",
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
"follow_these": "Sigue estos",
"formbricks_version": "Versión de Formbricks",
"full_name": "Nombre completo",
@@ -245,9 +236,7 @@
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar columna",
"id": "ID",
"image": "Imagen",
"images": "Imágenes",
"import": "Importar",
@@ -265,7 +254,6 @@
"key": "Clave",
"label": "Etiqueta",
"language": "Idioma",
"last_name": "Apellido",
"learn_more": "Saber más",
"license_expired": "License Expired",
"light_overlay": "Superposición clara",
@@ -315,7 +303,6 @@
"on": "Activado",
"only_one_file_allowed": "Solo se permite un archivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
"open_options": "Abrir opciones",
"option_id": "ID de opción",
"option_ids": "IDs de opciones",
"optional": "Opcional",
@@ -441,7 +428,6 @@
"top_right": "Superior derecha",
"try_again": "Intentar de nuevo",
"type": "Tipo",
"unknown_survey": "Encuesta desconocida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
"update": "Actualizar",
"updated": "Actualizado",
@@ -458,7 +444,6 @@
"variables": "Variables",
"verified_email": "Correo electrónico verificado",
"video": "Vídeo",
"view": "Ver",
"warning": "Advertencia",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "No pudimos verificar tu licencia porque el servidor de licencias no está accesible.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
},
"analysis": {
"charts": {
"OR": "O",
"add_chart_to_dashboard": "Añadir gráfico al panel de control",
"add_chart_to_dashboard_description": "Selecciona un panel de control para añadir este gráfico. El gráfico se guardará automáticamente.",
"add_filter": "Añadir filtro",
"add_to_dashboard": "Añadir al panel de control",
"advanced_chart_builder_config_prompt": "Configura tu gráfico y haz clic en \"Ejecutar consulta\" para previsualizar",
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
"ai_query_section_title": "Pregunta a tus datos",
"apply_changes": "Aplicar cambios",
"chart": "Gráfico",
"chart_added_to_dashboard": "¡Gráfico añadido al panel de control!",
"chart_builder_choose_chart_type": "Elige el tipo de gráfico",
"chart_data": "Datos del gráfico",
"chart_data_tab": "Datos",
"chart_deleted_successfully": "Gráfico eliminado correctamente",
"chart_deletion_error": "Error al eliminar el gráfico",
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
"chart_duplication_error": "Error al duplicar el gráfico",
"chart_name": "Nombre del gráfico",
"chart_name_placeholder": "Nombre del gráfico",
"chart_preview": "Vista previa del gráfico",
"chart_render_error": "Algo salió mal al renderizar este gráfico.",
"chart_saved_successfully": "¡Gráfico guardado correctamente!",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de líneas",
"chart_type_not_supported": "El tipo de gráfico \"{{chartType}}\" aún no está soportado",
"chart_type_pie": "Gráfico circular",
"chart_updated_successfully": "¡Gráfico actualizado correctamente!",
"configure_description": "Modifica el tipo de gráfico y otros ajustes para esta visualización.",
"configure_title": "Configurar gráfico",
"configure_type_label": "Tipo de gráfico",
"contains": "contiene",
"create_chart": "Crear gráfico",
"create_chart_description": "Usa IA para generar un gráfico o créalo manualmente.",
"custom_range": "Rango personalizado",
"dashboard": "Panel de control",
"dashboard_select_placeholder": "Selecciona un panel de control",
"data_label": "Datos",
"date_preset_last_30_days": "Últimos 30 días",
"date_preset_last_7_days": "Últimos 7 días",
"date_preset_last_month": "Último mes",
"date_preset_this_month": "Este mes",
"date_preset_this_quarter": "Este trimestre",
"date_preset_this_year": "Este año",
"date_preset_today": "Hoy",
"date_preset_yesterday": "Ayer",
"date_range": "Rango de fechas",
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
"dimensions": "Dimensiones",
"dimensions_toggle_description": "Agrupa datos por categorías. El orden importa para gráficos multidimensionales.",
"edit_chart_description": "Visualiza y edita la configuración de tu gráfico.",
"edit_chart_title": "Editar gráfico",
"enable_time_dimension": "Activar dimensión temporal",
"end_date": "Fecha de finalización",
"enter_a_name_for_your_chart": "Introduce un nombre para tu gráfico para guardarlo.",
"enter_value": "Introduce un valor",
"equals": "es igual a",
"failed_to_add_chart_to_dashboard": "Error al añadir el gráfico al panel de control",
"failed_to_execute_query": "Error al ejecutar la consulta",
"failed_to_load_chart": "Error al cargar el gráfico",
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
"failed_to_save_chart": "Error al guardar el gráfico",
"field": "Campo",
"field_label_average_score": "Puntuación media",
"field_label_collected_at": "Recopilado el",
"field_label_count": "Recuento",
"field_label_detractor_count": "Recuento de detractores",
"field_label_emotion": "Emoción",
"field_label_field_type": "Tipo de campo",
"field_label_nps_score": "Puntuación NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Recuento de pasivos",
"field_label_promoter_count": "Recuento de promotores",
"field_label_response_id": "ID de respuesta",
"field_label_sentiment": "Sentimiento",
"field_label_source_name": "Nombre de origen",
"field_label_source_type": "Tipo de origen",
"field_label_topic": "Tema",
"field_label_user_identifier": "Identificador de usuario",
"filters": "Filtros",
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
"generate_chart": "Generar gráfico",
"granularity": "Granularidad",
"granularity_day": "Día",
"granularity_hour": "Hora",
"granularity_month": "Mes",
"granularity_quarter": "Trimestre",
"granularity_week": "Semana",
"granularity_year": "Año",
"greater_than": "mayor que",
"greater_than_or_equal": "mayor o igual que",
"group_by": "Agrupar por",
"group_by_description": "Selecciona dimensiones para desglosar tus datos. El orden importa para gráficos multidimensionales.",
"guide_button": "Ver guía de campos",
"guide_chart_type": "Tipo de gráfico",
"guide_chart_type_desc": "Cómo se visualizan los datos: área, barras, líneas, circular o número grande. Elige según lo que quieras mostrar (tendencias, comparaciones, partes de un todo, etc.).",
"guide_dimensions": "Dimensiones (agrupar por)",
"guide_dimensions_desc": "Cómo divides o agrupas los datos. Cada dimensión se convierte en una categoría en el gráfico (p. ej., sentimiento, tipo de fuente, nombre de encuesta, canal, tema). El orden importa para gráficos multidimensionales.",
"guide_filters": "Filtros",
"guide_filters_desc": "Condiciones que limitan qué datos se incluyen. Cada filtro tiene un campo, un operador (igual, contiene, mayor que, etc.) y valores. Y = todos deben coincidir; O = cualquiera puede coincidir.",
"guide_measures": "Medidas (qué cuentas o agregas)",
"guide_measures_predefined": "Las medidas predefinidas son métricas preconstruidas de tus datos de feedback: recuento (respuestas totales), recuento de promotores/detractores/pasivos (segmentos NPS), puntuación NPS, puntuación promedio, tasa de finalización.",
"guide_quick_ref": "Referencia rápida",
"guide_term_dimension": "Campo categórico usado para agrupar o dividir datos",
"guide_term_filter": "Condición que limita qué filas se incluyen",
"guide_term_measure": "Valor numérico que agregas (contar, sumar, promediar, etc.)",
"guide_term_time": "Agrupación basada en tiempo con granularidad y rango de fechas",
"guide_time_dimension": "Dimensión temporal",
"guide_time_dimension_desc": "Agrupación basada en tiempo: elige un campo temporal (normalmente Recopilado en), granularidad (Hora, Día, Semana, Mes, etc.) y rango de fechas (predefinido o personalizado). Úsalo para tendencias a lo largo del tiempo.",
"guide_title": "Guía de campos del constructor de gráficos",
"is_not_set": "no está establecido",
"is_set": "está establecido",
"less_than": "menor que",
"less_than_or_equal": "menor o igual que",
"measures": "Medidas",
"no_charts_found": "No se encontraron gráficos.",
"no_dashboards_available": "No hay paneles de control disponibles",
"no_dashboards_create_first": "Crea primero un panel de control para añadirle gráficos.",
"no_data_available": "No hay datos disponibles",
"no_data_returned": "La consulta no ha devuelto datos",
"no_data_returned_for_chart": "No se han devuelto datos para el gráfico",
"no_grouping": "Ninguno (solo filtro)",
"no_valid_data_to_display": "No hay datos válidos para mostrar",
"not_contains": "no contiene",
"not_equals": "no es igual a",
"open_chart": "Abrir gráfico {{name}}",
"open_options": "Abrir opciones del gráfico",
"or_filter_logic": "O",
"original": "Original",
"please_enter_chart_name": "Introduce un nombre para el gráfico",
"please_select_at_least_one_measure": "Selecciona al menos una medida",
"please_select_dashboard": "Selecciona un panel de control",
"predefined_measures": "Medidas predefinidas",
"preset": "Preajuste",
"query_executed_successfully": "Consulta ejecutada correctamente",
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_dimensions": "Seleccionar dimensiones...",
"select_field": "Seleccionar campo",
"select_measures": "Seleccionar medidas...",
"select_preset": "Seleccionar preajuste",
"showing_first_n_of": "Mostrando las primeras {{n}} de {{count}} filas",
"start_date": "Fecha de inicio",
"time_dimension": "Dimensión temporal",
"time_dimension_toggle_description": "Añade agrupación basada en tiempo para tendencias a lo largo del tiempo."
},
"dashboards": {
"create_dashboard": "Crear panel de control",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
"create_failed": "Error al crear el panel de control",
"create_success": "Panel de control creado correctamente",
"dashboard_name": "Nombre del panel de control",
"dashboard_name_placeholder": "Mi panel de control",
"delete_confirmation": "¿Estás seguro de que quieres eliminar este panel de control? Esta acción no se puede deshacer.",
"delete_failed": "Error al eliminar el panel de control",
"delete_success": "Panel de control eliminado correctamente",
"description_optional": "Descripción (opcional)",
"description_placeholder": "Descripción del panel de control",
"duplicate_failed": "Error al duplicar el panel de control",
"duplicate_success": "Panel de control duplicado correctamente",
"no_dashboards_found": "No se han encontrado paneles de control.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
}
},
"connect": {
"congrats": "¡Enhorabuena!",
"connection_successful_message": "¡Bien hecho! Estamos conectados.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
"create_attribute": "Crear atributo",
"create_key": "Crear clave",
"create_new_attribute": "Crear atributo nuevo",
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
"custom_attributes": "Atributos personalizados",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {Esto eliminará el atributo seleccionado. Se perderán todos los datos de contacto asociados con este atributo.} other {Esto eliminará los atributos seleccionados. Se perderán todos los datos de contacto asociados con estos atributos.}}",
"delete_contact_confirmation": "Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá. Si este contacto tiene respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.} other {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con estos contactos. Cualquier segmentación y personalización basada en los datos de estos contactos se perderá. Si estos contactos tienen respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.}}",
"displays": "Visualizaciones",
"edit_attribute": "Editar atributo",
"edit_attribute_description": "Actualiza la etiqueta y la descripción de este atributo.",
"edit_attribute_values": "Editar atributos",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
"no_activity_yet": "Aún no hay actividad",
"no_published_link_surveys_available": "No hay encuestas de enlace publicadas disponibles. Por favor, publica primero una encuesta de enlace.",
"no_published_surveys": "No hay encuestas publicadas",
"no_responses_found": "No se encontraron respuestas",
@@ -867,8 +681,6 @@
"select_a_survey": "Selecciona una encuesta",
"select_attribute": "Seleccionar atributo",
"select_attribute_key": "Seleccionar clave de atributo",
"survey_viewed": "Encuesta vista",
"survey_viewed_at": "Vista el",
"system_attributes": "Atributos del sistema",
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
@@ -940,12 +752,7 @@
"link_google_sheet": "Vincular Google Sheet",
"link_new_sheet": "Vincular nueva hoja",
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión con Google Sheets ha caducado. Reconecta para continuar sincronizando respuestas. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
"spreadsheet_permission_error": "No tienes permiso para acceder a esta hoja de cálculo. Asegúrate de que la hoja de cálculo esté compartida con tu cuenta de Google y de que tengas acceso de escritura a la hoja de cálculo.",
"spreadsheet_url": "URL de la hoja de cálculo",
"token_expired_error": "El token de actualización de Google Sheets ha caducado o ha sido revocado. Reconecta la integración."
"spreadsheet_url": "URL de la hoja de cálculo"
},
"include_created_at": "Incluir fecha de creación",
"include_hidden_fields": "Incluir campos ocultos",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Respuestas filtradas (Excel)",
"generating_qr_code": "Generando código QR",
"impressions": "Impresiones",
"impressions_identified_only": "Solo se muestran impresiones de contactos identificados",
"impressions_tooltip": "Número de veces que se ha visto la encuesta.",
"in_app": {
"connection_description": "La encuesta se mostrará a los usuarios de tu sitio web que cumplan con los criterios enumerados a continuación",
@@ -2183,7 +1989,6 @@
"last_quarter": "Último trimestre",
"last_year": "Último año",
"limit": "Límite",
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"other_values_found": "Otros valores encontrados",
"overall": "General",

View File

@@ -133,7 +133,6 @@
"allow": "Autoriser",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
"analysis": "Analyse",
"and": "Et",
"and_response_limit_of": "et limite de réponse de",
"anonymous": "Anonyme",
@@ -150,8 +149,6 @@
"bottom_right": "En bas à droite",
"cancel": "Annuler",
"centered_modal": "Au centre",
"chart": "Graphique",
"charts": "Graphiques",
"choices": "Choix",
"choose_environment": "Choisir l'environnement",
"choose_organization": "Choisir l'organisation",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
"count_responses": "{value, plural, other {# réponses}}",
"create": "Créer",
"create_new_organization": "Créer une nouvelle organisation",
"create_segment": "Créer un segment",
"create_survey": "Créer un sondage",
@@ -191,8 +187,6 @@
"created_by": "Créé par",
"customer_success": "Succès Client",
"dark_overlay": "Foncée",
"dashboard": "Tableau de bord",
"dashboards": "Tableaux de bord",
"date": "Date",
"days": "jours",
"default": "Par défaut",
@@ -224,16 +218,13 @@
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
"error_component_title": "Erreur de chargement des ressources",
"error_loading_data": "Erreur lors du chargement des données",
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des projets",
"filter": "Filtre",
"finish": "Terminer",
"first_name": "Prénom",
"follow_these": "Suivez ceci",
"formbricks_version": "Version de Formbricks",
"full_name": "Nom complet",
@@ -245,9 +236,7 @@
"hidden": "Caché",
"hidden_field": "Champ caché",
"hidden_fields": "Champs cachés",
"hide": "Masquer",
"hide_column": "Cacher la colonne",
"id": "ID",
"image": "Image",
"images": "Images",
"import": "Importer",
@@ -265,7 +254,6 @@
"key": "Clé",
"label": "Étiquette",
"language": "Langue",
"last_name": "Nom de famille",
"learn_more": "En savoir plus",
"license_expired": "License Expired",
"light_overlay": "Claire",
@@ -315,7 +303,6 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"open_options": "Ouvrir les options",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"optional": "Facultatif",
@@ -441,7 +428,6 @@
"top_right": "En haut à droite",
"try_again": "Réessayer",
"type": "Type",
"unknown_survey": "Enquête inconnue",
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
"update": "Mise à jour",
"updated": "Mise à jour",
@@ -458,7 +444,6 @@
"variables": "Variables",
"verified_email": "Email vérifié",
"video": "Vidéo",
"view": "Afficher",
"warning": "Avertissement",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
},
"analysis": {
"charts": {
"OR": "OU",
"add_chart_to_dashboard": "Ajouter le graphique au tableau de bord",
"add_chart_to_dashboard_description": "Sélectionnez un tableau de bord pour y ajouter ce graphique. Le graphique sera enregistré automatiquement.",
"add_filter": "Ajouter un filtre",
"add_to_dashboard": "Ajouter au tableau de bord",
"advanced_chart_builder_config_prompt": "Configurez votre graphique et cliquez sur «Exécuter la requête» pour prévisualiser",
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière?",
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
"ai_query_section_title": "Interrogez vos données",
"apply_changes": "Appliquer les modifications",
"chart": "Graphique",
"chart_added_to_dashboard": "Graphique ajouté au tableau de bord!",
"chart_builder_choose_chart_type": "Choisir le type de graphique",
"chart_data": "Données du graphique",
"chart_data_tab": "Données",
"chart_deleted_successfully": "Graphique supprimé avec succès",
"chart_deletion_error": "Échec de la suppression du graphique",
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
"chart_duplication_error": "Échec de la duplication du graphique",
"chart_name": "Nom du graphique",
"chart_name_placeholder": "Nom du graphique",
"chart_preview": "Aperçu du graphique",
"chart_render_error": "Une erreur s'est produite lors du rendu de ce graphique.",
"chart_saved_successfully": "Graphique enregistré avec succès!",
"chart_type_area": "Graphique en aires",
"chart_type_bar": "Graphique à barres",
"chart_type_big_number": "Grand nombre",
"chart_type_line": "Graphique linéaire",
"chart_type_not_supported": "Le type de graphique « {{chartType}} » n'est pas encore pris en charge",
"chart_type_pie": "Graphique circulaire",
"chart_updated_successfully": "Graphique mis à jour avec succès!",
"configure_description": "Modifiez le type de graphique et d'autres paramètres pour cette visualisation.",
"configure_title": "Configurer le graphique",
"configure_type_label": "Type de graphique",
"contains": "contient",
"create_chart": "Créer un graphique",
"create_chart_description": "Utilisez l'IA pour générer un graphique ou créez-en un manuellement.",
"custom_range": "Plage personnalisée",
"dashboard": "Tableau de bord",
"dashboard_select_placeholder": "Sélectionnez un tableau de bord",
"data_label": "Données",
"date_preset_last_30_days": "30 derniers jours",
"date_preset_last_7_days": "7 derniers jours",
"date_preset_last_month": "Le mois dernier",
"date_preset_this_month": "Ce mois-ci",
"date_preset_this_quarter": "Ce trimestre",
"date_preset_this_year": "Cette année",
"date_preset_today": "Aujourd'hui",
"date_preset_yesterday": "Hier",
"date_range": "Plage de dates",
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique?",
"dimensions": "Dimensions",
"dimensions_toggle_description": "Regroupez les données par catégories. L'ordre est important pour les graphiques multidimensionnels.",
"edit_chart_description": "Consultez et modifiez la configuration de votre graphique.",
"edit_chart_title": "Modifier le graphique",
"enable_time_dimension": "Activer la dimension temporelle",
"end_date": "Date de fin",
"enter_a_name_for_your_chart": "Saisissez un nom pour votre graphique afin de l'enregistrer.",
"enter_value": "Saisissez une valeur",
"equals": "égal",
"failed_to_add_chart_to_dashboard": "Échec de l'ajout du graphique au tableau de bord",
"failed_to_execute_query": "Échec de l'exécution de la requête",
"failed_to_load_chart": "Échec du chargement du graphique",
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
"field": "Champ",
"field_label_average_score": "Score moyen",
"field_label_collected_at": "Collecté le",
"field_label_count": "Nombre",
"field_label_detractor_count": "Nombre de détracteurs",
"field_label_emotion": "Émotion",
"field_label_field_type": "Type de champ",
"field_label_nps_score": "Score NPS",
"field_label_nps_value": "Valeur NPS",
"field_label_passive_count": "Nombre de passifs",
"field_label_promoter_count": "Nombre de promoteurs",
"field_label_response_id": "ID de réponse",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nom de la source",
"field_label_source_type": "Type de source",
"field_label_topic": "Sujet",
"field_label_user_identifier": "Identifiant utilisateur",
"filters": "Filtres",
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
"generate_chart": "Générer le graphique",
"granularity": "Granularité",
"granularity_day": "Jour",
"granularity_hour": "Heure",
"granularity_month": "Mois",
"granularity_quarter": "Trimestre",
"granularity_week": "Semaine",
"granularity_year": "Année",
"greater_than": "supérieur à",
"greater_than_or_equal": "supérieur ou égal à",
"group_by": "Regrouper par",
"group_by_description": "Sélectionnez les dimensions pour décomposer vos données. L'ordre est important pour les graphiques multidimensionnels.",
"guide_button": "Voir le guide des champs",
"guide_chart_type": "Type de graphique",
"guide_chart_type_desc": "Comment les données sont visualisées: aire, barres, lignes, secteurs ou grand nombre. Choisissez en fonction de ce que vous souhaitez montrer (tendances, comparaisons, parties d'un tout, etc.).",
"guide_dimensions": "Dimensions (regrouper par)",
"guide_dimensions_desc": "Comment vous divisez ou regroupez les données. Chaque dimension devient une catégorie sur le graphique (par ex. sentiment, type de source, nom du sondage, canal, sujet). L'ordre est important pour les graphiques multidimensionnels.",
"guide_filters": "Filtres",
"guide_filters_desc": "Conditions qui limitent les données incluses. Chaque filtre a un champ, un opérateur (égal à, contient, supérieur à, etc.) et des valeurs. Et = tous doivent correspondre; Ou = n'importe lequel peut correspondre.",
"guide_measures": "Mesures (ce que vous comptez ou agrégez)",
"guide_measures_predefined": "Les mesures prédéfinies sont des métriques préconstruites à partir de vos données de feedback: count (total des réponses), promoter/detractor/passive count (segments NPS), NPS score, average score, completion rate.",
"guide_quick_ref": "Référence rapide",
"guide_term_dimension": "Champ catégoriel utilisé pour regrouper ou diviser les données",
"guide_term_filter": "Condition qui limite les lignes incluses",
"guide_term_measure": "Valeur numérique que vous agrégez (count, sum, avg, etc.)",
"guide_term_time": "Regroupement temporel avec granularité et plage de dates",
"guide_time_dimension": "Dimension temporelle",
"guide_time_dimension_desc": "Regroupement temporel: choisissez un champ temporel (généralement Collecté le), une granularité (Heure, Jour, Semaine, Mois, etc.) et une plage de dates (prédéfinie ou personnalisée). Utilisez pour les tendances dans le temps.",
"guide_title": "Guide des champs du créateur de graphiques",
"is_not_set": "n'est pas défini",
"is_set": "est défini",
"less_than": "inférieur à",
"less_than_or_equal": "inférieur ou égal à",
"measures": "Mesures",
"no_charts_found": "Aucun graphique trouvé.",
"no_dashboards_available": "Aucun tableau de bord disponible",
"no_dashboards_create_first": "Créez d'abord un tableau de bord pour y ajouter des graphiques.",
"no_data_available": "Aucune donnée disponible",
"no_data_returned": "Aucune donnée retournée par la requête",
"no_data_returned_for_chart": "Aucune donnée retournée pour le graphique",
"no_grouping": "Aucun (filtre uniquement)",
"no_valid_data_to_display": "Aucune donnée valide à afficher",
"not_contains": "ne contient pas",
"not_equals": "n'est pas égal à",
"open_chart": "Ouvrir le graphique {{name}}",
"open_options": "Ouvrir les options du graphique",
"or_filter_logic": "OU",
"original": "Original",
"please_enter_chart_name": "Veuillez saisir un nom de graphique",
"please_select_at_least_one_measure": "Veuillez sélectionner au moins une mesure",
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
"predefined_measures": "Mesures prédéfinies",
"preset": "Préréglage",
"query_executed_successfully": "Requête exécutée avec succès",
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
"save_chart": "Enregistrer le graphique",
"save_chart_dialog_title": "Enregistrer le graphique",
"select_dimensions": "Sélectionner les dimensions...",
"select_field": "Sélectionner un champ",
"select_measures": "Sélectionner les mesures...",
"select_preset": "Sélectionner un préréglage",
"showing_first_n_of": "Affichage des {{n}} premières lignes sur {{count}}",
"start_date": "Date de début",
"time_dimension": "Dimension temporelle",
"time_dimension_toggle_description": "Ajouter un regroupement temporel pour les tendances dans le temps."
},
"dashboards": {
"create_dashboard": "Créer un tableau de bord",
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
"create_failed": "Échec de la création du tableau de bord",
"create_success": "Tableau de bord créé avec succès!",
"dashboard_name": "Nom du tableau de bord",
"dashboard_name_placeholder": "Mon tableau de bord",
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce tableau de bord? Cette action est irréversible.",
"delete_failed": "Échec de la suppression du tableau de bord",
"delete_success": "Tableau de bord supprimé avec succès",
"description_optional": "Description (facultatif)",
"description_placeholder": "Description du tableau de bord",
"duplicate_failed": "Échec de la duplication du tableau de bord",
"duplicate_success": "Tableau de bord dupliqué avec succès!",
"no_dashboards_found": "Aucun tableau de bord trouvé.",
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
}
},
"connect": {
"congrats": "Félicitations !",
"connection_successful_message": "Bien joué ! Nous sommes connectés.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Actualiser les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"create_attribute": "Créer un attribut",
"create_key": "Créer une clé",
"create_new_attribute": "Créer un nouvel attribut",
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
"custom_attributes": "Attributs personnalisés",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {Cela supprimera l'attribut sélectionné. Toutes les données de contact associées à cet attribut seront perdues.} other {Cela supprimera les attributs sélectionnés. Toutes les données de contact associées à ces attributs seront perdues.}}",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
"displays": "Affichages",
"edit_attribute": "Modifier l'attribut",
"edit_attribute_description": "Mettez à jour l'étiquette et la description de cet attribut.",
"edit_attribute_values": "Modifier les attributs",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s): {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
"no_activity_yet": "Aucune activité pour le moment",
"no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.",
"no_published_surveys": "Aucune enquête publiée",
"no_responses_found": "Aucune réponse trouvée",
@@ -867,8 +681,6 @@
"select_a_survey": "Sélectionner une enquête",
"select_attribute": "Sélectionner un attribut",
"select_attribute_key": "Sélectionner une clé d'attribut",
"survey_viewed": "Enquête consultée",
"survey_viewed_at": "Consultée le",
"system_attributes": "Attributs système",
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
@@ -940,12 +752,7 @@
"link_google_sheet": "Lien Google Sheet",
"link_new_sheet": "Lier une nouvelle feuille",
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Votre connexion Google Sheets a expiré. Veuillez vous reconnecter pour continuer à synchroniser les réponses. Vos liens de feuilles de calcul et données existants seront préservés.",
"reconnect_button_tooltip": "Reconnectez l'intégration pour actualiser votre accès. Vos liens de feuilles de calcul et données existants seront préservés.",
"spreadsheet_permission_error": "Vous n'avez pas la permission d'accéder à cette feuille de calcul. Veuillez vous assurer que la feuille de calcul est partagée avec votre compte Google et que vous disposez d'un accès en écriture.",
"spreadsheet_url": "URL de la feuille de calcul",
"token_expired_error": "Le jeton d'actualisation Google Sheets a expiré ou a été révoqué. Veuillez reconnecter l'intégration."
"spreadsheet_url": "URL de la feuille de calcul"
},
"include_created_at": "Inclure la date de création",
"include_hidden_fields": "Inclure les champs cachés",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Réponses filtrées (Excel)",
"generating_qr_code": "Génération du code QR",
"impressions": "Impressions",
"impressions_identified_only": "Affichage uniquement des impressions des contacts identifiés",
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
"in_app": {
"connection_description": "Le sondage sera affiché aux utilisateurs de votre site web, qui correspondent aux critères listés ci-dessous",
@@ -2183,7 +1989,6 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"limit": "Limite",
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",

View File

@@ -133,7 +133,6 @@
"allow": "Engedélyezés",
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
"analysis": "Elemzés",
"and": "És",
"and_response_limit_of": "és kérdéskorlátja ennek:",
"anonymous": "Névtelen",
@@ -150,8 +149,6 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"chart": "Diagram",
"charts": "Diagramok",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
"choose_organization": "Szervezet kiválasztása",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
"create": "Létrehozás",
"create_new_organization": "Új szervezet létrehozása",
"create_segment": "Szakasz létrehozása",
"create_survey": "Kérdőív létrehozása",
@@ -191,8 +187,6 @@
"created_by": "Létrehozta",
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"dashboard": "Vezérlőpult",
"dashboards": "Irányítópultok",
"date": "Dátum",
"days": "napok",
"default": "Alapértelmezett",
@@ -224,16 +218,13 @@
"error": "Hiba",
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
"error_component_title": "Hiba az erőforrások betöltésekor",
"error_loading_data": "Hiba az adatok betöltése során",
"error_rate_limit_description": "A kérések legnagyobb száma elérve. Próbálja meg később újra.",
"error_rate_limit_title": "A sebességkorlát elérve",
"expand_rows": "Sorok kinyitása",
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
"follow_these": "Ezek követése",
"formbricks_version": "Formbricks verziója",
"full_name": "Teljes név",
@@ -245,9 +236,7 @@
"hidden": "Rejtett",
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
"hide": "Elrejtés",
"hide_column": "Oszlop elrejtése",
"id": "ID",
"image": "Kép",
"images": "Képek",
"import": "Importálás",
@@ -265,7 +254,6 @@
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
"last_name": "Vezetéknév",
"learn_more": "Tudjon meg többet",
"license_expired": "A licenc lejárt",
"light_overlay": "Világos rávetítés",
@@ -315,7 +303,6 @@
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
"open_options": "Beállítások megnyitása",
"option_id": "Választásazonosító",
"option_ids": "Választásazonosítók",
"optional": "Elhagyható",
@@ -441,7 +428,6 @@
"top_right": "Jobbra fent",
"try_again": "Próbálja újra",
"type": "Típus",
"unknown_survey": "Ismeretlen kérdőív",
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
"update": "Frissítés",
"updated": "Frissítve",
@@ -458,7 +444,6 @@
"variables": "Változók",
"verified_email": "Ellenőrzött e-mail-cím",
"video": "Videó",
"view": "Megtekintés",
"warning": "Figyelmeztetés",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nem tudtuk ellenőrizni a licencét, mert a licenckiszolgáló nem érhető el.",
"webhook": "Webhorog",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
},
"analysis": {
"charts": {
"OR": "VAGY",
"add_chart_to_dashboard": "Diagram hozzáadása a vezérlőpulthoz",
"add_chart_to_dashboard_description": "Válassz egy vezérlőpultot, amelyhez hozzáadod ezt a diagramot. A diagram automatikusan mentésre kerül.",
"add_filter": "Szűrő hozzáadása",
"add_to_dashboard": "Hozzáadás a vezérlőpulthoz",
"advanced_chart_builder_config_prompt": "Állítsd be a diagramot, és kattints a \"Lekérdezés futtatása\" gombra az előnézethez",
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
"ai_query_section_title": "Kérdezd meg az adataidat",
"apply_changes": "Módosítások alkalmazása",
"chart": "Diagram",
"chart_added_to_dashboard": "A diagram hozzáadva a vezérlőpulthoz!",
"chart_builder_choose_chart_type": "Válassz diagramtípust",
"chart_data": "Diagram adatai",
"chart_data_tab": "Adatok",
"chart_deleted_successfully": "A diagram sikeresen törölve",
"chart_deletion_error": "A diagram törlése sikertelen",
"chart_duplicated_successfully": "A diagram sikeresen duplikálva",
"chart_duplication_error": "A diagram duplikálása sikertelen",
"chart_name": "Diagram neve",
"chart_name_placeholder": "Diagram neve",
"chart_preview": "Diagram előnézete",
"chart_render_error": "Hiba történt a diagram megjelenítése során.",
"chart_saved_successfully": "A diagram sikeresen mentve!",
"chart_type_area": "Területdiagram",
"chart_type_bar": "Oszlopdiagram",
"chart_type_big_number": "Nagy szám",
"chart_type_line": "Vonaldiagram",
"chart_type_not_supported": "A(z) \"{{chartType}}\" diagramtípus még nem támogatott",
"chart_type_pie": "Kördiagram",
"chart_updated_successfully": "A diagram sikeresen frissítve!",
"configure_description": "Módosítsd a diagram típusát és egyéb beállításait ehhez a vizualizációhoz.",
"configure_title": "Diagram konfigurálása",
"configure_type_label": "Diagram típusa",
"contains": "tartalmazza",
"create_chart": "Diagram létrehozása",
"create_chart_description": "Használj mesterséges intelligenciát diagram generálásához, vagy készíts egyet manuálisan.",
"custom_range": "Egyéni tartomány",
"dashboard": "Vezérlőpult",
"dashboard_select_placeholder": "Válassz egy vezérlőpultot",
"data_label": "Adat",
"date_preset_last_30_days": "Elmúlt 30 nap",
"date_preset_last_7_days": "Elmúlt 7 nap",
"date_preset_last_month": "Elmúlt hónap",
"date_preset_this_month": "Ez a hónap",
"date_preset_this_quarter": "Ez a negyedév",
"date_preset_this_year": "Ez az év",
"date_preset_today": "Ma",
"date_preset_yesterday": "Tegnap",
"date_range": "Dátumtartomány",
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
"dimensions": "Dimenziók",
"dimensions_toggle_description": "Csoportosítsd az adatokat kategóriák szerint. A sorrend számít többdimenziós diagramoknál.",
"edit_chart_description": "Tekintsd meg és szerkeszd a diagram konfigurációját.",
"edit_chart_title": "Diagram szerkesztése",
"enable_time_dimension": "Időbeli dimenzió engedélyezése",
"end_date": "Befejezési dátum",
"enter_a_name_for_your_chart": "Add meg a diagram nevét a mentéshez.",
"enter_value": "Adj meg értéket",
"equals": "egyenlő",
"failed_to_add_chart_to_dashboard": "A diagram hozzáadása a vezérlőpulthoz sikertelen",
"failed_to_execute_query": "A lekérdezés végrehajtása sikertelen",
"failed_to_load_chart": "A diagram betöltése sikertelen",
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
"failed_to_save_chart": "A diagram mentése sikertelen",
"field": "Mező",
"field_label_average_score": "Átlagos pontszám",
"field_label_collected_at": "Gyűjtve",
"field_label_count": "Darabszám",
"field_label_detractor_count": "Kritikusok száma",
"field_label_emotion": "Érzelem",
"field_label_field_type": "Mező típusa",
"field_label_nps_score": "NPS pontszám",
"field_label_nps_value": "NPS érték",
"field_label_passive_count": "Passzívak száma",
"field_label_promoter_count": "Támogatók száma",
"field_label_response_id": "Válaszazonosító",
"field_label_sentiment": "Hangulat",
"field_label_source_name": "Forrás neve",
"field_label_source_type": "Forrás típusa",
"field_label_topic": "Téma",
"field_label_user_identifier": "Felhasználóazonosító",
"filters": "Szűrők",
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
"generate_chart": "Diagram generálása",
"granularity": "Részletesség",
"granularity_day": "Nap",
"granularity_hour": "óra",
"granularity_month": "hónap",
"granularity_quarter": "negyedév",
"granularity_week": "hét",
"granularity_year": "év",
"greater_than": "nagyobb mint",
"greater_than_or_equal": "nagyobb vagy egyenlő",
"group_by": "Csoportosítás",
"group_by_description": "Válassz dimenziókat az adatok lebontásához. A sorrend számít többdimenziós diagramok esetén.",
"guide_button": "Mezőkalauz megtekintése",
"guide_chart_type": "Diagram típusa",
"guide_chart_type_desc": "Az adatok megjelenítésének módja: terület, oszlop, vonal, kör vagy nagy szám. Válaszd azt, ami alapján meg szeretnéd jeleníteni (trendek, összehasonlítások, részek az egészből stb.).",
"guide_dimensions": "Dimenziók (csoportosítás)",
"guide_dimensions_desc": "Az adatok felosztásának vagy csoportosításának módja. Minden dimenzió egy kategóriává válik a diagramon (pl. hangulat, forrás típusa, felmérés neve, csatorna, téma). A sorrend számít többdimenziós diagramok esetén.",
"guide_filters": "Szűrők",
"guide_filters_desc": "Feltételek, amelyek korlátozzák, hogy mely adatok kerüljenek be. Minden szűrő tartalmaz egy mezőt, operátort (egyenlő, tartalmaz, nagyobb mint stb.) és értékeket. És = mindegyiknek egyeznie kell; Vagy = bármelyik egyezhet.",
"guide_measures": "Mértékek (amit számolsz vagy aggregálsz)",
"guide_measures_predefined": "Az előre definiált mértékek előre elkészített metrikák a visszajelzési adataidból: darabszám (összes válasz), támogató/ellenzői/passzív darabszám (NPS szegmensek), NPS pontszám, átlagos pontszám, befejezési arány.",
"guide_quick_ref": "Gyors referencia",
"guide_term_dimension": "Kategorikus mező, amelyet az adatok csoportosítására vagy felosztására használnak",
"guide_term_filter": "Feltétel, amely korlátozza, hogy mely sorok kerüljenek be",
"guide_term_measure": "Numerikus érték, amelyet aggregálsz (darabszám, összeg, átlag stb.)",
"guide_term_time": "Időalapú csoportosítás részletességgel és dátumtartománnyal",
"guide_time_dimension": "Idődimenziós",
"guide_time_dimension_desc": "Időalapú csoportosítás: válassz egy időmezőt (általában Gyűjtés időpontja), részletességet (Óra, Nap, Hét, Hónap stb.) és dátumtartományt (előre beállított vagy egyéni). Használd időbeli trendekhez.",
"guide_title": "Diagramkészítő útmutató",
"is_not_set": "nincs beállítva",
"is_set": "beállítva",
"less_than": "kisebb mint",
"less_than_or_equal": "kisebb vagy egyenlő",
"measures": "Mérőszámok",
"no_charts_found": "Nem található diagram.",
"no_dashboards_available": "Nincsenek elérhető vezérlőpultok",
"no_dashboards_create_first": "Először hozz létre egy vezérlőpultot, hogy diagramokat adhass hozzá.",
"no_data_available": "Nincsenek elérhető adatok",
"no_data_returned": "A lekérdezés nem adott vissza adatokat",
"no_data_returned_for_chart": "A diagram nem adott vissza adatokat",
"no_grouping": "Nincs (csak szűrés)",
"no_valid_data_to_display": "Nincsenek megjeleníthető érvényes adatok",
"not_contains": "nem tartalmazza",
"not_equals": "nem egyenlő",
"open_chart": "{{name}} diagram megnyitása",
"open_options": "Diagram beállításainak megnyitása",
"or_filter_logic": "VAGY",
"original": "Eredeti",
"please_enter_chart_name": "Kérjük, add meg a diagram nevét",
"please_select_at_least_one_measure": "Kérjük, válassz ki legalább egy mérőszámot",
"please_select_dashboard": "Kérjük, válassz egy vezérlőpultot",
"predefined_measures": "Előre definiált mérőszámok",
"preset": "Előbeállítás",
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
"save_chart": "Diagram mentése",
"save_chart_dialog_title": "Diagram mentése",
"select_dimensions": "Dimenziók kiválasztása...",
"select_field": "Mező kiválasztása",
"select_measures": "Mérőszámok kiválasztása...",
"select_preset": "Előbeállítás kiválasztása",
"showing_first_n_of": "Az első {{n}} sor megjelenítése {{count}} sorból",
"start_date": "Kezdési dátum",
"time_dimension": "Időbeli dimenzió",
"time_dimension_toggle_description": "Időalapú csoportosítás hozzáadása az időbeli trendek megjelenítéséhez."
},
"dashboards": {
"create_dashboard": "Vezérlőpult létrehozása",
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
"create_failed": "A vezérlőpult létrehozása sikertelen",
"create_success": "A vezérlőpult sikeresen létrehozva!",
"dashboard_name": "Vezérlőpult neve",
"dashboard_name_placeholder": "Saját vezérlőpult",
"delete_confirmation": "Biztosan törölni szeretné ezt a vezérlőpultot? Ez a művelet nem vonható vissza.",
"delete_failed": "A vezérlőpult törlése sikertelen",
"delete_success": "A vezérlőpult sikeresen törölve",
"description_optional": "Leírás (opcionális)",
"description_placeholder": "Vezérlőpult leírása",
"duplicate_failed": "A vezérlőpult másolása sikertelen",
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
"no_dashboards_found": "Nem található vezérlőpult.",
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
}
},
"connect": {
"congrats": "Gratulálunk!",
"connection_successful_message": "Szép munka! Kapcsolódtunk.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Partnerek frissítése",
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
"create_attribute": "Attribútum létrehozása",
"create_key": "Kulcs létrehozása",
"create_new_attribute": "Új attribútum létrehozása",
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
"custom_attributes": "Egyéni attribútumok",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {Ez törölni fogja a kiválasztott attribútumot. Az ehhez az attribútumhoz hozzárendelt összes partneradat el fog veszni.} other {Ez törölni fogja a kiválasztott attribútumokat. Az ezekhez az attribútumokhoz hozzárendelt összes partneradat el fog veszni.}}",
"delete_contact_confirmation": "Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ez a partner olyan válaszokkal rendelkezik, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.} other {Ez törölni fogja az ezekhez a partnerekhez tartozó összes kérdőívválaszt és partnerattribútumot. A partnerek adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ezek a partnerek olyan válaszokkal rendelkeznek, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.}}",
"displays": "Megjelenítések",
"edit_attribute": "Attribútum szerkesztése",
"edit_attribute_description": "Az attribútum címkéjének és leírásának frissítése.",
"edit_attribute_values": "Attribútumok szerkesztése",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "Érvénytelen CSV oszlopnév(nevek): {columns}. Az új attribútumokká váló oszlopnevek csak kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, és betűvel kell kezdődniük.",
"invalid_date_format": "Érvénytelen dátumformátum. Kérlek, adj meg egy érvényes dátumot.",
"invalid_number_format": "Érvénytelen számformátum. Kérlek, adj meg egy érvényes számot.",
"no_activity_yet": "Még nincs aktivitás",
"no_published_link_surveys_available": "Nem érhetők el közzétett hivatkozás-kérdőívek. Először tegyen közzé egy hivatkozás-kérdőívet.",
"no_published_surveys": "Nincsenek közzétett kérdőívek",
"no_responses_found": "Nem találhatók válaszok",
@@ -867,8 +681,6 @@
"select_a_survey": "Kérdőív kiválasztása",
"select_attribute": "Attribútum kiválasztása",
"select_attribute_key": "Attribútum kulcs kiválasztása",
"survey_viewed": "Kérdőív megtekintve",
"survey_viewed_at": "Megtekintve",
"system_attributes": "Rendszer attribútumok",
"unlock_contacts_description": "Partnerek kezelése és célzott kérdőívek kiküldése",
"unlock_contacts_title": "Partnerek feloldása egy magasabb csomaggal",
@@ -940,12 +752,7 @@
"link_google_sheet": "Google Táblázatok összekapcsolása",
"link_new_sheet": "Új táblázat összekapcsolása",
"no_integrations_yet": "A Google Táblázatok integrációi itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"reconnect_button": "Újrakapcsolódás",
"reconnect_button_description": "A Google Táblázatok kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő táblázathivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő táblázathivatkozások és adatok megmaradnak.",
"spreadsheet_permission_error": "Nincs jogosultsága a táblázat eléréséhez. Kérjük, győződjön meg arról, hogy a táblázat meg van osztva a Google-fiókjával, és írási jogosultsággal rendelkezik a táblázathoz.",
"spreadsheet_url": "Táblázat URL-e",
"token_expired_error": "A Google Táblázatok frissítési tokenje lejárt vagy visszavonásra került. Kérjük, csatlakoztassa újra az integrációt."
"spreadsheet_url": "Táblázat URL-e"
},
"include_created_at": "Létrehozva felvétele",
"include_hidden_fields": "Rejtett mezők felvétele",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Szűrt válaszok (Excel)",
"generating_qr_code": "QR-kód előállítása",
"impressions": "Benyomások",
"impressions_identified_only": "Csak az azonosított kapcsolatok megjelenítései láthatók",
"impressions_tooltip": "A kérdőív megtekintési alkalmainak száma.",
"in_app": {
"connection_description": "A kérdőív a webhelye azon felhasználóinak lesz megjelenítve, akik megfelelnek az alább felsorolt feltételeknek",
@@ -2183,7 +1989,6 @@
"last_quarter": "Elmúlt negyedév",
"last_year": "Elmúlt év",
"limit": "Korlát",
"no_identified_impressions": "Nincsenek megjelenítések azonosított kapcsolatoktól",
"no_responses_found": "Nem találhatók válaszok",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",

View File

@@ -133,7 +133,6 @@
"allow": "許可",
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
"analysis": "分析",
"and": "および",
"and_response_limit_of": "と回答数の上限",
"anonymous": "匿名",
@@ -150,8 +149,6 @@
"bottom_right": "右下",
"cancel": "キャンセル",
"centered_modal": "中央モーダル",
"chart": "チャート",
"charts": "チャート",
"choices": "選択肢",
"choose_environment": "環境を選択",
"choose_organization": "組織を選択",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, other {{value}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"create": "作成",
"create_new_organization": "新しい組織を作成",
"create_segment": "セグメントを作成",
"create_survey": "フォームを作成",
@@ -191,8 +187,6 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"dashboard": "ダッシュボード",
"dashboards": "ダッシュボード",
"date": "日付",
"days": "日",
"default": "デフォルト",
@@ -224,16 +218,13 @@
"error": "エラー",
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
"error_component_title": "リソース の 読み込み エラー",
"error_loading_data": "データの読み込みエラー",
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
"error_rate_limit_title": "レート制限を超えました",
"expand_rows": "行を展開",
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
"filter": "フィルター",
"finish": "完了",
"first_name": "名",
"follow_these": "こちらの手順に従って",
"formbricks_version": "Formbricksバージョン",
"full_name": "氏名",
@@ -245,9 +236,7 @@
"hidden": "非表示",
"hidden_field": "非表示フィールド",
"hidden_fields": "非表示フィールド",
"hide": "非表示",
"hide_column": "列を非表示",
"id": "ID",
"image": "画像",
"images": "画像",
"import": "インポート",
@@ -265,7 +254,6 @@
"key": "キー",
"label": "ラベル",
"language": "言語",
"last_name": "姓",
"learn_more": "詳細を見る",
"license_expired": "License Expired",
"light_overlay": "明るいオーバーレイ",
@@ -315,7 +303,6 @@
"on": "オン",
"only_one_file_allowed": "ファイルは1つのみ許可されています",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
"open_options": "オプションを開く",
"option_id": "オプションID",
"option_ids": "オプションID",
"optional": "任意",
@@ -441,7 +428,6 @@
"top_right": "右上",
"try_again": "もう一度お試しください",
"type": "種類",
"unknown_survey": "不明なフォーム",
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
"update": "更新",
"updated": "更新済み",
@@ -458,7 +444,6 @@
"variables": "変数",
"verified_email": "認証済みメールアドレス",
"video": "動画",
"view": "表示",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "ライセンスサーバーにアクセスできないため、ライセンスを認証できませんでした。",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
},
"analysis": {
"charts": {
"OR": "OR",
"add_chart_to_dashboard": "ダッシュボードにチャートを追加",
"add_chart_to_dashboard_description": "このチャートを追加するダッシュボードを選択してください。チャートは自動的に保存されます。",
"add_filter": "フィルターを追加",
"add_to_dashboard": "ダッシュボードに追加",
"advanced_chart_builder_config_prompt": "チャートを設定して「クエリを実行」をクリックしてプレビューを表示",
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
"ai_query_section_title": "データに質問する",
"apply_changes": "変更を適用",
"chart": "チャート",
"chart_added_to_dashboard": "チャートをダッシュボードに追加しました!",
"chart_builder_choose_chart_type": "チャートタイプを選択",
"chart_data": "チャートデータ",
"chart_data_tab": "データ",
"chart_deleted_successfully": "チャートを削除しました",
"chart_deletion_error": "チャートの削除に失敗しました",
"chart_duplicated_successfully": "チャートを複製しました",
"chart_duplication_error": "チャートの複製に失敗しました",
"chart_name": "チャート名",
"chart_name_placeholder": "チャート名",
"chart_preview": "チャートプレビュー",
"chart_render_error": "このチャートの表示中に問題が発生しました。",
"chart_saved_successfully": "チャートを保存しました!",
"chart_type_area": "エリアチャート",
"chart_type_bar": "棒グラフ",
"chart_type_big_number": "大きな数値",
"chart_type_line": "折れ線グラフ",
"chart_type_not_supported": "チャートタイプ「{{chartType}}」はまだサポートされていません",
"chart_type_pie": "円グラフ",
"chart_updated_successfully": "チャートを更新しました!",
"configure_description": "このビジュアライゼーションのチャートタイプやその他の設定を変更します。",
"configure_title": "チャートを設定",
"configure_type_label": "チャートタイプ",
"contains": "を含む",
"create_chart": "チャートを作成",
"create_chart_description": "AIを使用してチャートを生成するか、手動で作成します。",
"custom_range": "カスタム範囲",
"dashboard": "ダッシュボード",
"dashboard_select_placeholder": "ダッシュボードを選択",
"data_label": "データ",
"date_preset_last_30_days": "過去30日間",
"date_preset_last_7_days": "過去7日間",
"date_preset_last_month": "先月",
"date_preset_this_month": "今月",
"date_preset_this_quarter": "今四半期",
"date_preset_this_year": "今年",
"date_preset_today": "今日",
"date_preset_yesterday": "昨日",
"date_range": "日付範囲",
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
"dimensions": "ディメンション",
"dimensions_toggle_description": "カテゴリ別にデータをグループ化します。多次元チャートでは順序が重要です。",
"edit_chart_description": "チャート設定を表示および編集します。",
"edit_chart_title": "チャートを編集",
"enable_time_dimension": "時間ディメンションを有効化",
"end_date": "終了日",
"enter_a_name_for_your_chart": "チャートを保存するには名前を入力してください。",
"enter_value": "値を入力",
"equals": "と等しい",
"failed_to_add_chart_to_dashboard": "ダッシュボードへのチャート追加に失敗しました",
"failed_to_execute_query": "クエリの実行に失敗しました",
"failed_to_load_chart": "チャートの読み込みに失敗しました",
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
"failed_to_save_chart": "チャートの保存に失敗しました",
"field": "フィールド",
"field_label_average_score": "平均スコア",
"field_label_collected_at": "収集日時",
"field_label_count": "カウント",
"field_label_detractor_count": "批判者数",
"field_label_emotion": "感情",
"field_label_field_type": "フィールドタイプ",
"field_label_nps_score": "NPSスコア",
"field_label_nps_value": "NPS値",
"field_label_passive_count": "中立者数",
"field_label_promoter_count": "推奨者数",
"field_label_response_id": "回答ID",
"field_label_sentiment": "感情分析",
"field_label_source_name": "ソース名",
"field_label_source_type": "ソースタイプ",
"field_label_topic": "トピック",
"field_label_user_identifier": "ユーザー識別子",
"filters": "フィルター",
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
"generate_chart": "チャートを生成",
"granularity": "粒度",
"granularity_day": "日",
"granularity_hour": "時間",
"granularity_month": "月",
"granularity_quarter": "四半期",
"granularity_week": "週",
"granularity_year": "年",
"greater_than": "より大きい",
"greater_than_or_equal": "以上",
"group_by": "グループ化",
"group_by_description": "データを分類するディメンションを選択します。多次元チャートでは順序が重要です。",
"guide_button": "フィールドガイドを表示",
"guide_chart_type": "チャートタイプ",
"guide_chart_type_desc": "データの視覚化方法:エリア、棒グラフ、折れ線グラフ、円グラフ、またはビッグナンバー。表示したい内容(トレンド、比較、全体の一部など)に基づいて選択します。",
"guide_dimensions": "ディメンション(グループ化)",
"guide_dimensions_desc": "データを分割またはグループ化する方法。各ディメンションはチャート上のカテゴリになります(例:センチメント、ソースタイプ、サーベイ名、チャネル、トピック)。多次元チャートでは順序が重要です。",
"guide_filters": "フィルター",
"guide_filters_desc": "含めるデータを制限する条件。各フィルターにはフィールド、演算子(等しい、含む、より大きいなど)、および値があります。And = すべて一致する必要があります。Or = いずれか一致すればよい。",
"guide_measures": "メジャー(カウントまたは集計する内容)",
"guide_measures_predefined": "事前定義メジャーは、フィードバックデータから構築済みのメトリクスです:カウント(総回答数)、プロモーター/デトラクター/パッシブカウント(NPSセグメント)、NPSスコア、平均スコア、完了率。",
"guide_quick_ref": "クイックリファレンス",
"guide_term_dimension": "データをグループ化または分割するために使用されるカテゴリフィールド",
"guide_term_filter": "含める行を制限する条件",
"guide_term_measure": "集計する数値(count、sum、avgなど)",
"guide_term_time": "粒度と日付範囲を持つ時間ベースのグループ化",
"guide_time_dimension": "時間ディメンション",
"guide_time_dimension_desc": "時間ベースのグループ化:時間フィールド(通常は収集日時)、粒度(時間、日、週、月など)、日付範囲(プリセットまたはカスタム)を選択します。時系列のトレンド分析に使用します。",
"guide_title": "チャートビルダーフィールドガイド",
"is_not_set": "設定されていない",
"is_set": "設定されている",
"less_than": "より小さい",
"less_than_or_equal": "以下",
"measures": "メジャー",
"no_charts_found": "チャートが見つかりません。",
"no_dashboards_available": "利用可能なダッシュボードがありません",
"no_dashboards_create_first": "チャートを追加するには、まずダッシュボードを作成してください。",
"no_data_available": "利用可能なデータがありません",
"no_data_returned": "クエリからデータが返されませんでした",
"no_data_returned_for_chart": "チャートのデータが返されませんでした",
"no_grouping": "なし(フィルターのみ)",
"no_valid_data_to_display": "表示する有効なデータがありません",
"not_contains": "を含まない",
"not_equals": "と等しくない",
"open_chart": "チャート{{name}}を開く",
"open_options": "チャートオプションを開く",
"or_filter_logic": "OR",
"original": "オリジナル",
"please_enter_chart_name": "チャート名を入力してください",
"please_select_at_least_one_measure": "少なくとも1つのメジャーを選択してください",
"please_select_dashboard": "ダッシュボードを選択してください",
"predefined_measures": "事前定義されたメジャー",
"preset": "プリセット",
"query_executed_successfully": "クエリが正常に実行されました",
"reset_to_ai_suggestion": "AIの提案にリセット",
"save_chart": "チャートを保存",
"save_chart_dialog_title": "チャートを保存",
"select_dimensions": "ディメンションを選択...",
"select_field": "フィールドを選択",
"select_measures": "メジャーを選択...",
"select_preset": "プリセットを選択",
"showing_first_n_of": "{{count}}行中、最初の{{n}}行を表示",
"start_date": "開始日",
"time_dimension": "時間ディメンション",
"time_dimension_toggle_description": "時系列のトレンド分析のために時間ベースのグループ化を追加します。"
},
"dashboards": {
"create_dashboard": "ダッシュボードを作成",
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
"create_failed": "ダッシュボードの作成に失敗しました",
"create_success": "ダッシュボードを正常に作成しました!",
"dashboard_name": "ダッシュボード名",
"dashboard_name_placeholder": "マイダッシュボード",
"delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
"delete_failed": "ダッシュボードの削除に失敗しました",
"delete_success": "ダッシュボードを正常に削除しました",
"description_optional": "説明(任意)",
"description_placeholder": "ダッシュボードの説明",
"duplicate_failed": "ダッシュボードの複製に失敗しました",
"duplicate_success": "ダッシュボードを正常に複製しました!",
"no_dashboards_found": "ダッシュボードが見つかりません。",
"please_enter_name": "ダッシュボード名を入力してください"
}
},
"connect": {
"congrats": "おめでとうございます!",
"connection_successful_message": "うまくいきました!接続されました。",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "連絡先を更新",
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"create_attribute": "属性を作成",
"create_key": "キーを作成",
"create_new_attribute": "新しい属性を作成",
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
"custom_attributes": "カスタム属性",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {選択した属性を削除します。この属性に関連付けられたすべてのコンタクトデータは失われます。} other {選択した属性を削除します。これらの属性に関連付けられたすべてのコンタクトデータは失われます。}}",
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
"displays": "表示回数",
"edit_attribute": "属性を編集",
"edit_attribute_description": "この属性のラベルと説明を更新します。",
"edit_attribute_values": "属性を編集",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
"no_activity_yet": "まだアクティビティがありません",
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
"no_published_surveys": "公開されたフォームはありません",
"no_responses_found": "回答が見つかりません",
@@ -867,8 +681,6 @@
"select_a_survey": "フォームを選択",
"select_attribute": "属性を選択",
"select_attribute_key": "属性キーを選択",
"survey_viewed": "フォームを閲覧",
"survey_viewed_at": "閲覧日時",
"system_attributes": "システム属性",
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
"unlock_contacts_title": "上位プランで連絡先をアンロック",
@@ -940,12 +752,7 @@
"link_google_sheet": "スプレッドシートをリンク",
"link_new_sheet": "新しいシートをリンク",
"no_integrations_yet": "Google スプレッドシート連携は、追加するとここに表示されます。⏲️",
"reconnect_button": "再接続",
"reconnect_button_description": "Google Sheetsの接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のスプレッドシートリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のスプレッドシートリンクとデータは保持されます。",
"spreadsheet_permission_error": "このスプレッドシートにアクセスする権限がありません。スプレッドシートがGoogleアカウントと共有されており、書き込みアクセス権があることを確認してください。",
"spreadsheet_url": "スプレッドシートURL",
"token_expired_error": "Google Sheetsのリフレッシュトークンが期限切れになったか、取り消されました。統合を再接続してください。"
"spreadsheet_url": "スプレッドシートURL"
},
"include_created_at": "作成日時を含める",
"include_hidden_fields": "非表示フィールドを含める",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "フィルター済み回答 (Excel)",
"generating_qr_code": "QRコードを生成中",
"impressions": "表示回数",
"impressions_identified_only": "識別済みコンタクトからのインプレッションのみを表示しています",
"impressions_tooltip": "フォームが表示された回数。",
"in_app": {
"connection_description": "このフォームは、以下の条件に一致するあなたのウェブサイトのユーザーに表示されます",
@@ -2183,7 +1989,6 @@
"last_quarter": "前四半期",
"last_year": "昨年",
"limit": "制限",
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
"no_responses_found": "回答が見つかりません",
"other_values_found": "他の値が見つかりました",
"overall": "全体",

View File

@@ -133,7 +133,6 @@
"allow": "Toestaan",
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
"analysis": "Analyse",
"and": "En",
"and_response_limit_of": "en responslimiet van",
"anonymous": "Anoniem",
@@ -150,8 +149,6 @@
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
"centered_modal": "Gecentreerd modaal",
"chart": "Grafiek",
"charts": "Grafieken",
"choices": "Keuzes",
"choose_environment": "Kies omgeving",
"choose_organization": "Kies organisatie",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
"create": "Creëren",
"create_new_organization": "Creëer een nieuwe organisatie",
"create_segment": "Segment maken",
"create_survey": "Enquête maken",
@@ -191,8 +187,6 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
@@ -224,16 +218,13 @@
"error": "Fout",
"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_loading_data": "Fout bij het laden van gegevens",
"error_rate_limit_description": "Maximaal aantal verzoeken bereikt. Probeer het later opnieuw.",
"error_rate_limit_title": "Tarieflimiet overschreden",
"expand_rows": "Vouw rijen uit",
"failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt",
"failed_to_load_organizations": "Laden van organisaties mislukt",
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
"filter": "Filter",
"finish": "Finish",
"first_name": "Voornaam",
"follow_these": "Volg deze",
"formbricks_version": "Formbricks-versie",
"full_name": "Volledige naam",
@@ -245,9 +236,7 @@
"hidden": "Verborgen",
"hidden_field": "Verborgen veld",
"hidden_fields": "Verborgen velden",
"hide": "Verbergen",
"hide_column": "Kolom verbergen",
"id": "ID",
"image": "Afbeelding",
"images": "Afbeeldingen",
"import": "Importeren",
@@ -265,7 +254,6 @@
"key": "Sleutel",
"label": "Label",
"language": "Taal",
"last_name": "Achternaam",
"learn_more": "Meer informatie",
"license_expired": "License Expired",
"light_overlay": "Lichte overlay",
@@ -315,7 +303,6 @@
"on": "Op",
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
"open_options": "Opties openen",
"option_id": "Optie-ID",
"option_ids": "Optie-ID's",
"optional": "Optioneel",
@@ -441,7 +428,6 @@
"top_right": "Rechtsboven",
"try_again": "Probeer het opnieuw",
"type": "Type",
"unknown_survey": "Onbekende enquête",
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
"update": "Update",
"updated": "Bijgewerkt",
@@ -458,7 +444,6 @@
"variables": "Variabelen",
"verified_email": "Geverifieerde e-mail",
"video": "Video",
"view": "Bekijken",
"warning": "Waarschuwing",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We kunnen uw licentie niet verifiëren omdat de licentieserver niet bereikbaar is.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
},
"analysis": {
"charts": {
"OR": "OF",
"add_chart_to_dashboard": "Grafiek toevoegen aan dashboard",
"add_chart_to_dashboard_description": "Selecteer een dashboard om deze grafiek aan toe te voegen. De grafiek wordt automatisch opgeslagen.",
"add_filter": "Filter toevoegen",
"add_to_dashboard": "Toevoegen aan dashboard",
"advanced_chart_builder_config_prompt": "Configureer je grafiek en klik op \"Query uitvoeren\" om een voorbeeld te zien",
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
"ai_query_section_title": "Vraag het aan je data",
"apply_changes": "Wijzigingen toepassen",
"chart": "Grafiek",
"chart_added_to_dashboard": "Grafiek toegevoegd aan dashboard!",
"chart_builder_choose_chart_type": "Kies grafiektype",
"chart_data": "Grafiekdata",
"chart_data_tab": "Data",
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
"chart_deletion_error": "Verwijderen van grafiek mislukt",
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
"chart_duplication_error": "Dupliceren van grafiek mislukt",
"chart_name": "Grafieknaam",
"chart_name_placeholder": "Grafieknaam",
"chart_preview": "Grafiekvoorbeeld",
"chart_render_error": "Er is iets misgegaan bij het weergeven van deze grafiek.",
"chart_saved_successfully": "Grafiek succesvol opgeslagen!",
"chart_type_area": "Vlakdiagram",
"chart_type_bar": "Staafdiagram",
"chart_type_big_number": "Groot getal",
"chart_type_line": "Lijndiagram",
"chart_type_not_supported": "Grafiektype \"{{chartType}}\" wordt nog niet ondersteund",
"chart_type_pie": "Cirkeldiagram",
"chart_updated_successfully": "Grafiek succesvol bijgewerkt!",
"configure_description": "Pas het diagramtype en andere instellingen voor deze visualisatie aan.",
"configure_title": "Diagram configureren",
"configure_type_label": "Diagramtype",
"contains": "bevat",
"create_chart": "Diagram maken",
"create_chart_description": "Gebruik AI om een diagram te genereren of bouw er handmatig een.",
"custom_range": "Aangepast bereik",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "Selecteer een dashboard",
"data_label": "Data",
"date_preset_last_30_days": "Laatste 30 dagen",
"date_preset_last_7_days": "Laatste 7 dagen",
"date_preset_last_month": "Vorige maand",
"date_preset_this_month": "Deze maand",
"date_preset_this_quarter": "Dit kwartaal",
"date_preset_this_year": "Dit jaar",
"date_preset_today": "Vandaag",
"date_preset_yesterday": "Gisteren",
"date_range": "Datumbereik",
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
"dimensions": "Dimensies",
"dimensions_toggle_description": "Groepeer data op categorieën. Volgorde is belangrijk voor multidimensionale diagrammen.",
"edit_chart_description": "Bekijk en bewerk je diagramconfiguratie.",
"edit_chart_title": "Diagram bewerken",
"enable_time_dimension": "Tijdsdimensie inschakelen",
"end_date": "Einddatum",
"enter_a_name_for_your_chart": "Voer een naam in voor je diagram om het op te slaan.",
"enter_value": "Voer waarde in",
"equals": "is gelijk aan",
"failed_to_add_chart_to_dashboard": "Diagram toevoegen aan dashboard mislukt",
"failed_to_execute_query": "Query uitvoeren mislukt",
"failed_to_load_chart": "Diagram laden mislukt",
"failed_to_load_chart_data": "Diagramdata laden mislukt",
"failed_to_save_chart": "Opslaan van diagram mislukt",
"field": "Veld",
"field_label_average_score": "Gemiddelde score",
"field_label_collected_at": "Verzameld op",
"field_label_count": "Aantal",
"field_label_detractor_count": "Aantal detractors",
"field_label_emotion": "Emotie",
"field_label_field_type": "Veldtype",
"field_label_nps_score": "NPS-score",
"field_label_nps_value": "NPS-waarde",
"field_label_passive_count": "Aantal passieven",
"field_label_promoter_count": "Aantal promoters",
"field_label_response_id": "Antwoord-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Bronnaam",
"field_label_source_type": "Brontype",
"field_label_topic": "Onderwerp",
"field_label_user_identifier": "Gebruikersidentificatie",
"filters": "Filters",
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
"generate_chart": "Diagram genereren",
"granularity": "Granulariteit",
"granularity_day": "Dag",
"granularity_hour": "Uur",
"granularity_month": "Maand",
"granularity_quarter": "Kwartaal",
"granularity_week": "Week",
"granularity_year": "Jaar",
"greater_than": "groter dan",
"greater_than_or_equal": "groter dan of gelijk aan",
"group_by": "Groeperen op",
"group_by_description": "Selecteer dimensies om je gegevens op te splitsen. De volgorde is belangrijk voor multidimensionale diagrammen.",
"guide_button": "Veldgids bekijken",
"guide_chart_type": "Diagramtype",
"guide_chart_type_desc": "Hoe de gegevens worden gevisualiseerd: vlak, staaf, lijn, cirkel of groot getal. Kies op basis van wat je wilt tonen (trends, vergelijkingen, delen van een geheel, etc.).",
"guide_dimensions": "Dimensies (groeperen op)",
"guide_dimensions_desc": "Hoe je de gegevens splitst of groepeert. Elke dimensie wordt een categorie in het diagram (bijv. sentiment, brontype, enquêtenaam, kanaal, onderwerp). De volgorde is belangrijk voor multidimensionale diagrammen.",
"guide_filters": "Filters",
"guide_filters_desc": "Voorwaarden die bepalen welke gegevens worden opgenomen. Elk filter heeft een veld, operator (is gelijk aan, bevat, groter dan, etc.) en waarden. En = alle moeten overeenkomen; Of = één mag overeenkomen.",
"guide_measures": "Metingen (wat je telt of aggregeert)",
"guide_measures_predefined": "Voorgedefinieerde metingen zijn vooraf gebouwde statistieken uit je feedbackgegevens: aantal (totaal aantal reacties), promotor-/detractor-/passief aantal (NPS-segmenten), NPS-score, gemiddelde score, voltooiingspercentage.",
"guide_quick_ref": "Snelle referentie",
"guide_term_dimension": "Categorisch veld gebruikt om gegevens te groeperen of splitsen",
"guide_term_filter": "Voorwaarde die bepaalt welke rijen worden opgenomen",
"guide_term_measure": "Numerieke waarde die je aggregeert (count, sum, avg, etc.)",
"guide_term_time": "Tijdgebaseerde groepering met granulariteit en datumbereik",
"guide_time_dimension": "Tijddimensie",
"guide_time_dimension_desc": "Tijdgebaseerde groepering: kies een tijdveld (meestal Verzameld op), granulariteit (Uur, Dag, Week, Maand, etc.) en datumbereik (vooraf ingesteld of aangepast). Gebruik voor trends over tijd.",
"guide_title": "Veldgids voor diagrambouwer",
"is_not_set": "is niet ingesteld",
"is_set": "is ingesteld",
"less_than": "kleiner dan",
"less_than_or_equal": "kleiner dan of gelijk aan",
"measures": "Metingen",
"no_charts_found": "Geen diagrammen gevonden.",
"no_dashboards_available": "Geen dashboards beschikbaar",
"no_dashboards_create_first": "Maak eerst een dashboard aan om er diagrammen aan toe te voegen.",
"no_data_available": "Geen gegevens beschikbaar",
"no_data_returned": "Geen gegevens geretourneerd uit query",
"no_data_returned_for_chart": "Geen gegevens geretourneerd voor diagram",
"no_grouping": "Geen (alleen filteren)",
"no_valid_data_to_display": "Geen geldige gegevens om weer te geven",
"not_contains": "bevat niet",
"not_equals": "is niet gelijk aan",
"open_chart": "Open diagram {{name}}",
"open_options": "Open diagramopties",
"or_filter_logic": "OF",
"original": "Origineel",
"please_enter_chart_name": "Voer een diagramnaam in",
"please_select_at_least_one_measure": "Selecteer ten minste één meting",
"please_select_dashboard": "Selecteer een dashboard",
"predefined_measures": "Vooraf gedefinieerde metingen",
"preset": "Voorinstelling",
"query_executed_successfully": "Query succesvol uitgevoerd",
"reset_to_ai_suggestion": "Herstel naar AI-suggestie",
"save_chart": "Diagram opslaan",
"save_chart_dialog_title": "Diagram opslaan",
"select_dimensions": "Selecteer dimensies...",
"select_field": "Selecteer veld",
"select_measures": "Selecteer metingen...",
"select_preset": "Selecteer voorinstelling",
"showing_first_n_of": "Eerste {{n}} van {{count}} rijen worden getoond",
"start_date": "Startdatum",
"time_dimension": "Tijdsdimensie",
"time_dimension_toggle_description": "Voeg tijdgebaseerde groepering toe voor trends over tijd."
},
"dashboards": {
"create_dashboard": "Dashboard creëren",
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
"create_failed": "Dashboard creëren mislukt",
"create_success": "Dashboard succesvol aangemaakt!",
"dashboard_name": "Dashboardnaam",
"dashboard_name_placeholder": "Mijn dashboard",
"delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_failed": "Dashboard verwijderen mislukt",
"delete_success": "Dashboard succesvol verwijderd",
"description_optional": "Beschrijving (optioneel)",
"description_placeholder": "Dashboardbeschrijving",
"duplicate_failed": "Dashboard dupliceren mislukt",
"duplicate_success": "Dashboard succesvol gedupliceerd!",
"no_dashboards_found": "Geen dashboards gevonden.",
"please_enter_name": "Voer een dashboardnaam in"
}
},
"connect": {
"congrats": "Gefeliciteerd!",
"connection_successful_message": "Goed gedaan! We zijn verbonden.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Vernieuw contacten",
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
"create_attribute": "Attribuut aanmaken",
"create_key": "Sleutel aanmaken",
"create_new_attribute": "Nieuw attribuut aanmaken",
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
"custom_attributes": "Aangepaste kenmerken",
@@ -840,7 +656,6 @@
"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_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_description": "Werk het label en de beschrijving voor dit attribuut bij.",
"edit_attribute_values": "Attributen bewerken",
@@ -852,7 +667,6 @@
"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_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_surveys": "Geen gepubliceerde enquêtes",
"no_responses_found": "Geen reacties gevonden",
@@ -867,8 +681,6 @@
"select_a_survey": "Selecteer een enquête",
"select_attribute": "Selecteer Kenmerk",
"select_attribute_key": "Selecteer kenmerksleutel",
"survey_viewed": "Enquête bekeken",
"survey_viewed_at": "Bekeken op",
"system_attributes": "Systeemkenmerken",
"unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes",
"unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement",
@@ -940,12 +752,7 @@
"link_google_sheet": "Link Google Spreadsheet",
"link_new_sheet": "Nieuw blad koppelen",
"no_integrations_yet": "Uw Google Spreadsheet-integraties verschijnen hier zodra u ze toevoegt. ⏲️",
"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."
"spreadsheet_url": "Spreadsheet-URL"
},
"include_created_at": "Inclusief gemaakt op",
"include_hidden_fields": "Inclusief verborgen velden",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Gefilterde reacties (Excel)",
"generating_qr_code": "QR-code genereren",
"impressions": "Indrukken",
"impressions_identified_only": "Alleen weergaven van geïdentificeerde contacten worden getoond",
"impressions_tooltip": "Aantal keren dat de enquête is bekeken.",
"in_app": {
"connection_description": "De enquête wordt getoond aan gebruikers van uw website die voldoen aan de onderstaande criteria",
@@ -2183,7 +1989,6 @@
"last_quarter": "Laatste kwartaal",
"last_year": "Vorig jaar",
"limit": "Beperken",
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
"no_responses_found": "Geen reacties gevonden",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",

View File

@@ -133,7 +133,6 @@
"allow": "permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
@@ -150,8 +149,6 @@
"bottom_right": "Canto Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolher ambiente",
"choose_organization": "Escolher organização",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
@@ -191,8 +187,6 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "sobreposição escura",
"dashboard": "Painel",
"dashboards": "Painéis",
"date": "Encontro",
"days": "dias",
"default": "Padrão",
@@ -224,16 +218,13 @@
"error": "Erro",
"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_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_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Terminar",
"first_name": "Primeiro nome",
"follow_these": "Siga esses",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -245,9 +236,7 @@
"hidden": "Escondido",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar coluna",
"id": "ID",
"image": "imagem",
"images": "Imagens",
"import": "importar",
@@ -265,7 +254,6 @@
"key": "Chave",
"label": "Etiqueta",
"language": "Língua",
"last_name": "Sobrenome",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "sobreposição leve",
@@ -315,7 +303,6 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"open_options": "Abrir opções",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"optional": "Opcional",
@@ -441,7 +428,6 @@
"top_right": "Canto Superior Direito",
"try_again": "Tenta de novo",
"type": "Tipo",
"unknown_survey": "Pesquisa desconhecida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "atualizar",
"updated": "atualizado",
@@ -458,7 +444,6 @@
"variables": "Variáveis",
"verified_email": "Email Verificado",
"video": "vídeo",
"view": "Visualizar",
"warning": "Aviso",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não conseguimos verificar sua licença porque o servidor de licenças está inacessível.",
"webhook": "webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
},
"analysis": {
"charts": {
"OR": "OU",
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
"add_chart_to_dashboard_description": "Selecione um painel para adicionar este gráfico. O gráfico será salvo automaticamente.",
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configure seu gráfico e clique em \"Executar consulta\" para visualizar",
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
"ai_query_section_title": "Pergunte aos seus dados",
"apply_changes": "Aplicar alterações",
"chart": "Gráfico",
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
"chart_data": "Dados do gráfico",
"chart_data_tab": "Dados",
"chart_deleted_successfully": "Gráfico excluído com sucesso",
"chart_deletion_error": "Falha ao excluir gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_name": "Nome do gráfico",
"chart_name_placeholder": "Nome do gráfico",
"chart_preview": "Visualização do gráfico",
"chart_render_error": "Algo deu errado ao renderizar este gráfico.",
"chart_saved_successfully": "Gráfico salvo com sucesso!",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de linhas",
"chart_type_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
"chart_type_pie": "Gráfico de pizza",
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
"configure_description": "Modifique o tipo de gráfico e outras configurações para esta visualização.",
"configure_title": "Configurar gráfico",
"configure_type_label": "Tipo de gráfico",
"contains": "contém",
"create_chart": "Criar gráfico",
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
"custom_range": "Intervalo personalizado",
"dashboard": "Painel",
"dashboard_select_placeholder": "Selecione um painel",
"data_label": "Dados",
"date_preset_last_30_days": "Últimos 30 dias",
"date_preset_last_7_days": "Últimos 7 dias",
"date_preset_last_month": "Último mês",
"date_preset_this_month": "Este mês",
"date_preset_this_quarter": "Este trimestre",
"date_preset_this_year": "Este ano",
"date_preset_today": "Hoje",
"date_preset_yesterday": "Ontem",
"date_range": "Intervalo de datas",
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
"dimensions": "Dimensões",
"dimensions_toggle_description": "Agrupe dados por categorias. A ordem importa para gráficos multidimensionais.",
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
"edit_chart_title": "Editar gráfico",
"enable_time_dimension": "Ativar dimensão de tempo",
"end_date": "Data final",
"enter_a_name_for_your_chart": "Digite um nome para o seu gráfico para salvá-lo.",
"enter_value": "Digite o valor",
"equals": "igual",
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
"failed_to_execute_query": "Falha ao executar consulta",
"failed_to_load_chart": "Falha ao carregar gráfico",
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
"failed_to_save_chart": "Falha ao salvar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_collected_at": "Coletado em",
"field_label_count": "Contagem",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_nps_score": "Pontuação de NPS",
"field_label_nps_value": "Valor de NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_response_id": "ID da resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da fonte",
"field_label_source_type": "Tipo de fonte",
"field_label_topic": "Tópico",
"field_label_user_identifier": "Identificador do usuário",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
"generate_chart": "Gerar gráfico",
"granularity": "Granularidade",
"granularity_day": "Dia",
"granularity_hour": "Hora",
"granularity_month": "Mês",
"granularity_quarter": "Trimestre",
"granularity_week": "Semana",
"granularity_year": "Ano",
"greater_than": "maior que",
"greater_than_or_equal": "maior ou igual a",
"group_by": "Agrupar por",
"group_by_description": "Selecione dimensões para detalhar seus dados. A ordem importa para gráficos multidimensionais.",
"guide_button": "Ver guia de campos",
"guide_chart_type": "Tipo de gráfico",
"guide_chart_type_desc": "Como os dados são visualizados: área, barra, linha, pizza ou número grande. Escolha com base no que você deseja mostrar (tendências, comparações, partes de um todo, etc.).",
"guide_dimensions": "Dimensões (agrupar por)",
"guide_dimensions_desc": "Como você divide ou agrupa os dados. Cada dimensão se torna uma categoria no gráfico (ex.: sentimento, tipo de origem, nome da pesquisa, canal, tópico). A ordem importa para gráficos multidimensionais.",
"guide_filters": "Filtros",
"guide_filters_desc": "Condições que limitam quais dados são incluídos. Cada filtro tem um campo, operador (igual a, contém, maior que, etc.) e valores. E = todos devem corresponder; Ou = qualquer um pode corresponder.",
"guide_measures": "Medidas (o que você conta ou agrega)",
"guide_measures_predefined": "Medidas predefinidas são métricas pré-construídas dos seus dados de feedback: contagem (total de respostas), contagem de promotores/detratores/passivos (segmentos NPS), pontuação NPS, pontuação média, taxa de conclusão.",
"guide_quick_ref": "Referência rápida",
"guide_term_dimension": "Campo categórico usado para agrupar ou dividir dados",
"guide_term_filter": "Condição que limita quais linhas são incluídas",
"guide_term_measure": "Valor numérico que você agrega (count, sum, avg, etc.)",
"guide_term_time": "Agrupamento baseado em tempo com granularidade e intervalo de datas",
"guide_time_dimension": "Dimensão de tempo",
"guide_time_dimension_desc": "Agrupamento baseado em tempo: escolha um campo de tempo (geralmente Coletado em), granularidade (Hora, Dia, Semana, Mês, etc.) e intervalo de datas (predefinido ou personalizado). Use para tendências ao longo do tempo.",
"guide_title": "Guia de campos do construtor de gráficos",
"is_not_set": "não está definido",
"is_set": "está definido",
"less_than": "menor que",
"less_than_or_equal": "menor ou igual a",
"measures": "Medidas",
"no_charts_found": "Nenhum gráfico encontrado.",
"no_dashboards_available": "Nenhum painel disponível",
"no_dashboards_create_first": "Crie um painel primeiro para adicionar gráficos a ele.",
"no_data_available": "Nenhum dado disponível",
"no_data_returned": "Nenhum dado retornado da consulta",
"no_data_returned_for_chart": "Nenhum dado retornado para o gráfico",
"no_grouping": "Nenhum (apenas filtro)",
"no_valid_data_to_display": "Nenhum dado válido para exibir",
"not_contains": "não contém",
"not_equals": "diferente de",
"open_chart": "Abrir gráfico {{name}}",
"open_options": "Abrir opções do gráfico",
"or_filter_logic": "OU",
"original": "Original",
"please_enter_chart_name": "Por favor, insira um nome para o gráfico",
"please_select_at_least_one_measure": "Por favor, selecione pelo menos uma medida",
"please_select_dashboard": "Por favor, selecione um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Redefinir para sugestão da IA",
"save_chart": "Salvar gráfico",
"save_chart_dialog_title": "Salvar gráfico",
"select_dimensions": "Selecionar dimensões...",
"select_field": "Selecionar campo",
"select_measures": "Selecionar medidas...",
"select_preset": "Selecionar predefinição",
"showing_first_n_of": "Mostrando os primeiros {{n}} de {{count}} registros",
"start_date": "Data inicial",
"time_dimension": "Dimensão temporal",
"time_dimension_toggle_description": "Adicione agrupamento baseado em tempo para visualizar tendências ao longo do tempo."
},
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Digite um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_success": "Painel criado com sucesso!",
"dashboard_name": "Nome do painel",
"dashboard_name_placeholder": "Meu painel",
"delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
"delete_failed": "Falha ao excluir painel",
"delete_success": "Painel excluído com sucesso",
"description_optional": "Descrição (opcional)",
"description_placeholder": "Descrição do painel",
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"no_dashboards_found": "Nenhum painel encontrado.",
"please_enter_name": "Por favor, digite um nome para o painel"
}
},
"connect": {
"congrats": "Parabéns!",
"connection_successful_message": "Mandou bem! Estamos conectados.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_key": "Criar chave",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
"custom_attributes": "Atributos personalizados",
@@ -840,7 +656,6 @@
"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_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_description": "Atualize a etiqueta e a descrição deste atributo.",
"edit_attribute_values": "Editar atributos",
@@ -852,7 +667,6 @@
"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_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_surveys": "Sem pesquisas publicadas",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -867,8 +681,6 @@
"select_a_survey": "Selecione uma pesquisa",
"select_attribute": "Selecionar Atributo",
"select_attribute_key": "Selecionar chave de atributo",
"survey_viewed": "Pesquisa visualizada",
"survey_viewed_at": "Visualizada em",
"system_attributes": "Atributos do sistema",
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
@@ -940,12 +752,7 @@
"link_google_sheet": "Link da Planilha do Google",
"link_new_sheet": "Vincular nova planilha",
"no_integrations_yet": "Suas integrações do Google Sheets vão aparecer aqui assim que você adicioná-las. ⏲️",
"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."
"spreadsheet_url": "URL da planilha"
},
"include_created_at": "Incluir Data de Criação",
"include_hidden_fields": "Incluir Campos Ocultos",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Respostas filtradas (Excel)",
"generating_qr_code": "Gerando código QR",
"impressions": "Impressões",
"impressions_identified_only": "Mostrando apenas impressões de contatos identificados",
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
"in_app": {
"connection_description": "A pesquisa será exibida para usuários do seu site, que atendam aos critérios listados abaixo",
@@ -2183,7 +1989,6 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"limit": "Limite",
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",

View File

@@ -133,7 +133,6 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
@@ -150,8 +149,6 @@
"bottom_right": "Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
"choose_organization": "Escolher organização",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
@@ -191,8 +187,6 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "Sobreposição escura",
"dashboard": "Painel",
"dashboards": "Dashboards",
"date": "Data",
"days": "dias",
"default": "Padrão",
@@ -224,16 +218,13 @@
"error": "Erro",
"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_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_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Concluir",
"first_name": "Primeiro nome",
"follow_these": "Siga estes",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -245,9 +236,7 @@
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar coluna",
"id": "ID",
"image": "Imagem",
"images": "Imagens",
"import": "Importar",
@@ -265,7 +254,6 @@
"key": "Chave",
"label": "Etiqueta",
"language": "Idioma",
"last_name": "Apelido",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "Sobreposição leve",
@@ -315,7 +303,6 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"open_options": "Abrir opções",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"optional": "Opcional",
@@ -441,7 +428,6 @@
"top_right": "Superior Direito",
"try_again": "Tente novamente",
"type": "Tipo",
"unknown_survey": "Inquérito desconhecido",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "Atualizar",
"updated": "Atualizado",
@@ -458,7 +444,6 @@
"variables": "Variáveis",
"verified_email": "Email verificado",
"video": "Vídeo",
"view": "Ver",
"warning": "Aviso",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não foi possível verificar a sua licença porque o servidor de licenças está inacessível.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
},
"analysis": {
"charts": {
"OR": "OU",
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
"add_chart_to_dashboard_description": "Seleciona um painel para adicionar este gráfico. O gráfico será guardado automaticamente.",
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configura o teu gráfico e clica em \"Executar consulta\" para pré-visualizar",
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
"ai_query_section_title": "Pergunta aos teus dados",
"apply_changes": "Aplicar alterações",
"chart": "Gráfico",
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
"chart_data": "Dados do gráfico",
"chart_data_tab": "Dados",
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
"chart_deletion_error": "Falha ao eliminar gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_name": "Nome do gráfico",
"chart_name_placeholder": "Nome do gráfico",
"chart_preview": "Pré-visualização do gráfico",
"chart_render_error": "Algo correu mal ao renderizar este gráfico.",
"chart_saved_successfully": "Gráfico guardado com sucesso!",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de linhas",
"chart_type_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
"chart_type_pie": "Gráfico circular",
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
"configure_description": "Modifique o tipo de gráfico e outras definições para esta visualização.",
"configure_title": "Configurar gráfico",
"configure_type_label": "Tipo de gráfico",
"contains": "contém",
"create_chart": "Criar gráfico",
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
"custom_range": "Intervalo personalizado",
"dashboard": "Painel",
"dashboard_select_placeholder": "Selecione um painel",
"data_label": "Dados",
"date_preset_last_30_days": "Últimos 30 dias",
"date_preset_last_7_days": "Últimos 7 dias",
"date_preset_last_month": "Último mês",
"date_preset_this_month": "Este mês",
"date_preset_this_quarter": "Este trimestre",
"date_preset_this_year": "Este ano",
"date_preset_today": "Hoje",
"date_preset_yesterday": "Ontem",
"date_range": "Intervalo de datas",
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
"dimensions": "Dimensões",
"dimensions_toggle_description": "Agrupe dados por categorias. A ordem é importante para gráficos multidimensionais.",
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
"edit_chart_title": "Editar gráfico",
"enable_time_dimension": "Ativar dimensão temporal",
"end_date": "Data de fim",
"enter_a_name_for_your_chart": "Introduza um nome para o seu gráfico para o guardar.",
"enter_value": "Introduza o valor",
"equals": "igual",
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
"failed_to_execute_query": "Falha ao executar consulta",
"failed_to_load_chart": "Falha ao carregar gráfico",
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
"failed_to_save_chart": "Falha ao guardar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_collected_at": "Recolhido em",
"field_label_count": "Contagem",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_nps_score": "Pontuação NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_response_id": "ID de resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da origem",
"field_label_source_type": "Tipo de origem",
"field_label_topic": "Tópico",
"field_label_user_identifier": "Identificador de utilizador",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
"generate_chart": "Gerar gráfico",
"granularity": "Granularidade",
"granularity_day": "Dia",
"granularity_hour": "Hora",
"granularity_month": "Mês",
"granularity_quarter": "Trimestre",
"granularity_week": "Semana",
"granularity_year": "Ano",
"greater_than": "maior que",
"greater_than_or_equal": "maior ou igual a",
"group_by": "Agrupar por",
"group_by_description": "Selecione dimensões para desagregar os seus dados. A ordem é importante para gráficos multidimensionais.",
"guide_button": "Ver guia de campos",
"guide_chart_type": "Tipo de gráfico",
"guide_chart_type_desc": "Como os dados são visualizados: área, barras, linhas, circular ou número grande. Escolha com base no que pretende mostrar (tendências, comparações, partes de um todo, etc.).",
"guide_dimensions": "Dimensões (agrupar por)",
"guide_dimensions_desc": "Como divide ou agrupa os dados. Cada dimensão torna-se uma categoria no gráfico (por exemplo, sentimento, tipo de origem, nome do inquérito, canal, tópico). A ordem é importante para gráficos multidimensionais.",
"guide_filters": "Filtros",
"guide_filters_desc": "Condições que limitam quais dados são incluídos. Cada filtro tem um campo, operador (igual, contém, maior que, etc.) e valores. E = todos devem corresponder; Ou = qualquer um pode corresponder.",
"guide_measures": "Medidas (o que conta ou agrega)",
"guide_measures_predefined": "As medidas predefinidas são métricas pré-construídas a partir dos seus dados de feedback: contagem (total de respostas), contagem de promotores/detratores/passivos (segmentos NPS), pontuação NPS, pontuação média, taxa de conclusão.",
"guide_quick_ref": "Referência rápida",
"guide_term_dimension": "Campo categórico usado para agrupar ou dividir dados",
"guide_term_filter": "Condição que limita quais linhas são incluídas",
"guide_term_measure": "Valor numérico que agrega (contar, somar, média, etc.)",
"guide_term_time": "Agrupamento baseado em tempo com granularidade e intervalo de datas",
"guide_time_dimension": "Dimensão temporal",
"guide_time_dimension_desc": "Agrupamento baseado em tempo: escolha um campo temporal (geralmente Recolhido em), granularidade (Hora, Dia, Semana, Mês, etc.) e intervalo de datas (predefinido ou personalizado). Use para tendências ao longo do tempo.",
"guide_title": "Guia de campos do criador de gráficos",
"is_not_set": "não está definido",
"is_set": "está definido",
"less_than": "menor que",
"less_than_or_equal": "menor ou igual a",
"measures": "Medidas",
"no_charts_found": "Nenhum gráfico encontrado.",
"no_dashboards_available": "Nenhum painel disponível",
"no_dashboards_create_first": "Cria primeiro um painel para adicionar gráficos.",
"no_data_available": "Nenhum dado disponível",
"no_data_returned": "Nenhum dado devolvido pela consulta",
"no_data_returned_for_chart": "Nenhum dado devolvido para o gráfico",
"no_grouping": "Nenhum (apenas filtro)",
"no_valid_data_to_display": "Nenhum dado válido para exibir",
"not_contains": "não contém",
"not_equals": "não é igual a",
"open_chart": "Abrir gráfico {{name}}",
"open_options": "Abrir opções do gráfico",
"or_filter_logic": "OU",
"original": "Original",
"please_enter_chart_name": "Por favor, introduz um nome para o gráfico",
"please_select_at_least_one_measure": "Por favor, seleciona pelo menos uma medida",
"please_select_dashboard": "Por favor, seleciona um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Repor sugestão da IA",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_dimensions": "Selecionar dimensões...",
"select_field": "Selecionar campo",
"select_measures": "Selecionar medidas...",
"select_preset": "Selecionar predefinição",
"showing_first_n_of": "A mostrar as primeiras {{n}} de {{count}} linhas",
"start_date": "Data de início",
"time_dimension": "Dimensão temporal",
"time_dimension_toggle_description": "Adicionar agrupamento temporal para tendências ao longo do tempo."
},
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_success": "Painel criado com sucesso!",
"dashboard_name": "Nome do painel",
"dashboard_name_placeholder": "O meu painel",
"delete_confirmation": "Tem a certeza de que pretende eliminar este painel? Esta ação não pode ser revertida.",
"delete_failed": "Falha ao eliminar painel",
"delete_success": "Painel eliminado com sucesso",
"description_optional": "Descrição (opcional)",
"description_placeholder": "Descrição do painel",
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"no_dashboards_found": "Nenhum painel encontrado.",
"please_enter_name": "Por favor, introduza um nome para o painel"
}
},
"connect": {
"congrats": "Parabéns!",
"connection_successful_message": "Muito bem! Estamos ligados.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_key": "Criar chave",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
"custom_attributes": "Atributos personalizados",
@@ -840,7 +656,6 @@
"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_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_description": "Atualize a etiqueta e a descrição deste atributo.",
"edit_attribute_values": "Editar atributos",
@@ -852,7 +667,6 @@
"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_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_surveys": "Sem inquéritos publicados",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -867,8 +681,6 @@
"select_a_survey": "Selecione um inquérito",
"select_attribute": "Selecionar Atributo",
"select_attribute_key": "Selecionar chave de atributo",
"survey_viewed": "Inquérito visualizado",
"survey_viewed_at": "Visualizado em",
"system_attributes": "Atributos do sistema",
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
@@ -940,12 +752,7 @@
"link_google_sheet": "Ligar Folha do Google",
"link_new_sheet": "Ligar nova Folha",
"no_integrations_yet": "As suas integrações com o Google Sheets aparecerão aqui assim que as adicionar. ⏲️",
"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."
"spreadsheet_url": "URL da folha de cálculo"
},
"include_created_at": "Incluir Criado Em",
"include_hidden_fields": "Incluir Campos Ocultos",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Respostas filtradas (Excel)",
"generating_qr_code": "A gerar código QR",
"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.",
"in_app": {
"connection_description": "O questionário será exibido aos utilizadores do seu website que correspondam aos critérios listados abaixo",
@@ -2183,7 +1989,6 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"limit": "Limite",
"no_identified_impressions": "Sem impressões de contactos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",

View File

@@ -133,7 +133,6 @@
"allow": "Permite",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
"analysis": "Analiză",
"and": "Și",
"and_response_limit_of": "și limită răspuns",
"anonymous": "Anonim",
@@ -150,8 +149,6 @@
"bottom_right": "Dreapta Jos",
"cancel": "Anulare",
"centered_modal": "Modală centralizată",
"chart": "Grafic",
"charts": "Grafice",
"choices": "Alegeri",
"choose_environment": "Alege mediul",
"choose_organization": "Alege organizația",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
"create": "Creează",
"create_new_organization": "Creează organizație nouă",
"create_segment": "Creați segment",
"create_survey": "Creează sondaj",
@@ -191,8 +187,6 @@
"created_by": "Creat de",
"customer_success": "Succesul Clientului",
"dark_overlay": "Suprapunere întunecată",
"dashboard": "Tablou de bord",
"dashboards": "Tablouri de bord",
"date": "Dată",
"days": "zile",
"default": "Implicit",
@@ -224,16 +218,13 @@
"error": "Eroare",
"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_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_title": "Limită de cereri depășită",
"expand_rows": "Extinde rândurile",
"failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
"filter": "Filtru",
"finish": "Finalizează",
"first_name": "Prenume",
"follow_these": "Urmați acestea",
"formbricks_version": "Versiunea Formbricks",
"full_name": "Nume complet",
@@ -245,9 +236,7 @@
"hidden": "Ascuns",
"hidden_field": "Câmp ascuns",
"hidden_fields": "Câmpuri ascunse",
"hide": "Ascunde",
"hide_column": "Ascunde coloana",
"id": "ID",
"image": "Imagine",
"images": "Imagini",
"import": "Import",
@@ -265,7 +254,6 @@
"key": "Cheie",
"label": "Etichetă",
"language": "Limba",
"last_name": "Nume de familie",
"learn_more": "Află mai multe",
"license_expired": "License Expired",
"light_overlay": "Suprapunere ușoară",
@@ -315,7 +303,6 @@
"on": "Pe",
"only_one_file_allowed": "Este permis doar un fișier",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
"open_options": "Deschide opțiunile",
"option_id": "ID opțiune",
"option_ids": "ID-uri opțiuni",
"optional": "Opțional",
@@ -441,7 +428,6 @@
"top_right": "Dreapta Sus",
"try_again": "Încearcă din nou",
"type": "Tip",
"unknown_survey": "Chestionar necunoscut",
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
"update": "Actualizare",
"updated": "Actualizat",
@@ -458,7 +444,6 @@
"variables": "Variante",
"verified_email": "Email verificat",
"video": "Video",
"view": "Vezi",
"warning": "Avertisment",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
},
"analysis": {
"charts": {
"OR": "SAU",
"add_chart_to_dashboard": "Adaugă grafic la Tablou de Bord",
"add_chart_to_dashboard_description": "Selectează un tablou de bord la care să adaugi acest grafic. Graficul va fi salvat automat.",
"add_filter": "Adaugă filtru",
"add_to_dashboard": "Adaugă la Tablou de Bord",
"advanced_chart_builder_config_prompt": "Configurează graficul și apasă pe \"Rulează interogarea\" pentru previzualizare",
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
"ai_query_section_title": "Întreabă-ți datele",
"apply_changes": "Aplică modificările",
"chart": "Grafic",
"chart_added_to_dashboard": "Grafic adăugat la tablou de bord!",
"chart_builder_choose_chart_type": "Alege tipul de grafic",
"chart_data": "Datele graficului",
"chart_data_tab": "Date",
"chart_deleted_successfully": "Graficul a fost șters cu succes",
"chart_deletion_error": "Nu s-a putut șterge graficul",
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
"chart_duplication_error": "Nu s-a putut duplica graficul",
"chart_name": "Numele graficului",
"chart_name_placeholder": "Numele graficului",
"chart_preview": "Previzualizare grafic",
"chart_render_error": "Ceva nu a mers bine la afișarea acestui grafic.",
"chart_saved_successfully": "Graficul a fost salvat cu succes!",
"chart_type_area": "Grafic de tip arie",
"chart_type_bar": "Grafic de tip bară",
"chart_type_big_number": "Număr mare",
"chart_type_line": "Grafic de tip linie",
"chart_type_not_supported": "Tipul de grafic \"{{chartType}}\" nu este încă suportat",
"chart_type_pie": "Grafic de tip plăcintă",
"chart_updated_successfully": "Graficul a fost actualizat cu succes!",
"configure_description": "Modifică tipul graficului și alte setări pentru această vizualizare.",
"configure_title": "Configurează graficul",
"configure_type_label": "Tip grafic",
"contains": "conține",
"create_chart": "Creează grafic",
"create_chart_description": "Folosește AI pentru a genera un grafic sau creează unul manual.",
"custom_range": "Interval personalizat",
"dashboard": "Tablou de bord",
"dashboard_select_placeholder": "Selectează un tablou de bord",
"data_label": "Date",
"date_preset_last_30_days": "Ultimele 30 de zile",
"date_preset_last_7_days": "Ultimele 7 zile",
"date_preset_last_month": "Ultima lună",
"date_preset_this_month": "Luna aceasta",
"date_preset_this_quarter": "Trimestrul acesta",
"date_preset_this_year": "Anul acesta",
"date_preset_today": "Astăzi",
"date_preset_yesterday": "Ieri",
"date_range": "Interval de date",
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
"dimensions": "Dimensiuni",
"dimensions_toggle_description": "Grupează datele pe categorii. Ordinea contează pentru graficele multidimensionale.",
"edit_chart_description": "Vezi și editează configurația graficului tău.",
"edit_chart_title": "Editează graficul",
"enable_time_dimension": "Activează dimensiunea de timp",
"end_date": "Data de sfârșit",
"enter_a_name_for_your_chart": "Introdu un nume pentru grafic ca să îl salvezi.",
"enter_value": "Introdu valoarea",
"equals": "egal",
"failed_to_add_chart_to_dashboard": "Nu s-a putut adăuga graficul în tablou de bord",
"failed_to_execute_query": "Nu s-a putut executa interogarea",
"failed_to_load_chart": "Nu s-a putut încărca graficul",
"failed_to_load_chart_data": "Nu s-au putut încărca datele graficului",
"failed_to_save_chart": "Nu s-a putut salva graficul",
"field": "Câmp",
"field_label_average_score": "Scor mediu",
"field_label_collected_at": "Colectat la",
"field_label_count": "Număr",
"field_label_detractor_count": "Număr de detractori",
"field_label_emotion": "Emoție",
"field_label_field_type": "Tip câmp",
"field_label_nps_score": "Scor NPS",
"field_label_nps_value": "Valoare NPS",
"field_label_passive_count": "Număr de pasivi",
"field_label_promoter_count": "Număr de promotori",
"field_label_response_id": "ID răspuns",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nume sursă",
"field_label_source_type": "Tip sursă",
"field_label_topic": "Subiect",
"field_label_user_identifier": "Identificator utilizator",
"filters": "Filtre",
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
"generate_chart": "Generează grafic",
"granularity": "Granularitate",
"granularity_day": "Zi",
"granularity_hour": "Oră",
"granularity_month": "Lună",
"granularity_quarter": "Trimestru",
"granularity_week": "Săptămână",
"granularity_year": "An",
"greater_than": "mai mare decât",
"greater_than_or_equal": "mai mare sau egal cu",
"group_by": "Grupează după",
"group_by_description": "Selectează dimensiunile după care să împarți datele. Ordinea contează pentru graficele multidimensionale.",
"guide_button": "Vezi ghidul de câmpuri",
"guide_chart_type": "Tip grafic",
"guide_chart_type_desc": "Cum sunt vizualizate datele: Zonă, Bară, Linie, Plăcintă sau Număr mare. Alege în funcție de ce vrei să arăți (tendințe, comparații, părți dintr-un întreg etc.).",
"guide_dimensions": "Dimensiuni (Grupează după)",
"guide_dimensions_desc": "Cum împarți sau grupezi datele. Fiecare dimensiune devine o categorie pe grafic (ex: Sentiment, Tip sursă, Nume sondaj, Canal, Subiect). Ordinea contează pentru graficele multidimensionale.",
"guide_filters": "Filtre",
"guide_filters_desc": "Condiții care limitează ce date sunt incluse. Fiecare filtru are un câmp, un operator (egal, conține, mai mare decât etc.) și valori. Și = toate trebuie să se potrivească; Sau = oricare poate să se potrivească.",
"guide_measures": "Măsuri (ce numeri sau agregi)",
"guide_measures_predefined": "Măsurile predefinite sunt metrici gata făcute din datele tale de feedback: Număr (răspunsuri totale), Număr Promoter/Detractor/Passive (segmente NPS), Scor NPS, Scor mediu, Rată de completare.",
"guide_quick_ref": "Referință rapidă",
"guide_term_dimension": "Câmp categorial folosit pentru a grupa sau împărți datele",
"guide_term_filter": "Condiție care limitează ce rânduri sunt incluse",
"guide_term_measure": "Valoare numerică pe care o agregi (count, sum, avg etc.)",
"guide_term_time": "Grupare pe bază de timp cu granularitate și interval de date",
"guide_time_dimension": "Dimensiune de timp",
"guide_time_dimension_desc": "Grupare pe bază de timp: alege un câmp de timp (de obicei Colectat la), granularitate (Oră, Zi, Săptămână, Lună etc.) și interval de date (presetat sau personalizat). Folosește pentru a vedea tendințe în timp.",
"guide_title": "Ghid pentru crearea graficelor",
"is_not_set": "nu este setat",
"is_set": "este setat",
"less_than": "mai mic decât",
"less_than_or_equal": "mai mic sau egal",
"measures": "Măsurători",
"no_charts_found": "Nu s-au găsit grafice.",
"no_dashboards_available": "Nu există tablouri de bord disponibile",
"no_dashboards_create_first": "Creează mai întâi un tablou de bord pentru a adăuga grafice.",
"no_data_available": "Nu există date disponibile",
"no_data_returned": "Nu au fost returnate date din interogare",
"no_data_returned_for_chart": "Nu au fost returnate date pentru grafic",
"no_grouping": "Niciuna (doar filtru)",
"no_valid_data_to_display": "Nu există date valide de afișat",
"not_contains": "nu conține",
"not_equals": "nu este egal",
"open_chart": "Deschide graficul {{name}}",
"open_options": "Deschide opțiunile graficului",
"or_filter_logic": "SAU",
"original": "Original",
"please_enter_chart_name": "Te rugăm să introduci un nume pentru grafic",
"please_select_at_least_one_measure": "Te rugăm să selectezi cel puțin o măsurătoare",
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
"predefined_measures": "Măsurători predefinite",
"preset": "Presetare",
"query_executed_successfully": "Interogarea a fost executată cu succes",
"reset_to_ai_suggestion": "Resetează la sugestia AI",
"save_chart": "Salvează graficul",
"save_chart_dialog_title": "Salvează graficul",
"select_dimensions": "Selectează dimensiuni...",
"select_field": "Selectează câmpul",
"select_measures": "Selectează măsuri...",
"select_preset": "Selectează presetarea",
"showing_first_n_of": "Se afișează primele {{n}} din {{count}} rânduri",
"start_date": "Data de început",
"time_dimension": "Dimensiune temporală",
"time_dimension_toggle_description": "Adaugă grupare pe bază de timp pentru tendințe în timp."
},
"dashboards": {
"create_dashboard": "Creează tablou de bord",
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
"create_failed": "Crearea tabloului de bord a eșuat",
"create_success": "Tablou de bord creat cu succes!",
"dashboard_name": "Nume tablou de bord",
"dashboard_name_placeholder": "Tabloul meu de bord",
"delete_confirmation": "Ești sigur că vrei să ștergi acest tablou de bord? Această acțiune nu poate fi anulată.",
"delete_failed": "Ștergerea tabloului de bord a eșuat",
"delete_success": "Tablou de bord șters cu succes",
"description_optional": "Descriere (opțional)",
"description_placeholder": "Descriere tablou de bord",
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
"duplicate_success": "Tablou de bord duplicat cu succes!",
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
}
},
"connect": {
"congrats": "Felicitări!",
"connection_successful_message": "Bravo! Suntem conectați.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Reîmprospătare contacte",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"create_attribute": "Creează atribut",
"create_key": "Creează cheie",
"create_new_attribute": "Creează atribut nou",
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
"custom_attributes": "Atribute personalizate",
@@ -840,7 +656,6 @@
"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_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_description": "Actualizează eticheta și descrierea acestui atribut.",
"edit_attribute_values": "Editează atributele",
@@ -852,7 +667,6 @@
"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_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_surveys": "Nu există sondaje publicate",
"no_responses_found": "Nu s-au găsit răspunsuri",
@@ -867,8 +681,6 @@
"select_a_survey": "Selectați un sondaj",
"select_attribute": "Selectează atributul",
"select_attribute_key": "Selectează cheia atributului",
"survey_viewed": "Chestionar vizualizat",
"survey_viewed_at": "Vizualizat la",
"system_attributes": "Atribute de sistem",
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
@@ -940,12 +752,7 @@
"link_google_sheet": "Leagă Google 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. ⏲️",
"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."
"spreadsheet_url": "URL foaie de calcul"
},
"include_created_at": "Include data creării",
"include_hidden_fields": "Include câmpuri ascunse",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Răspunsuri filtrate (Excel)",
"generating_qr_code": "Se generează codul QR",
"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.",
"in_app": {
"connection_description": "Sondajul va fi afișat utilizatorilor site-ului dvs. web, care îndeplinesc criteriile enumerate mai jos",
@@ -2183,7 +1989,6 @@
"last_quarter": "Ultimul trimestru",
"last_year": "Anul trecut",
"limit": "Limită",
"no_identified_impressions": "Nicio impresie de la contactele identificate",
"no_responses_found": "Nu s-au găsit răspunsuri",
"other_values_found": "Alte valori găsite",
"overall": "General",

View File

@@ -133,7 +133,6 @@
"allow": "Разрешить",
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
"analysis": "Аналитика",
"and": "и",
"and_response_limit_of": "и лимит ответов",
"anonymous": "Аноним",
@@ -150,8 +149,6 @@
"bottom_right": "Внизу справа",
"cancel": "Отмена",
"centered_modal": "Центрированное модальное окно",
"chart": "График",
"charts": "Графики",
"choices": "Варианты",
"choose_environment": "Выберите среду",
"choose_organization": "Выберите организацию",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
"create": "Создать",
"create_new_organization": "Создать новую организацию",
"create_segment": "Создать сегмент",
"create_survey": "Создать опрос",
@@ -191,8 +187,6 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"dashboard": "Панель управления",
"dashboards": "Дашборды",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
@@ -224,16 +218,13 @@
"error": "Ошибка",
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
"error_component_title": "Ошибка загрузки ресурсов",
"error_loading_data": "Ошибка загрузки данных",
"error_rate_limit_description": "Достигнуто максимальное количество запросов. Пожалуйста, попробуйте позже.",
"error_rate_limit_title": "Превышен лимит запросов",
"expand_rows": "Развернуть строки",
"failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена",
"failed_to_load_organizations": "Не удалось загрузить организации",
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
"filter": "Фильтр",
"finish": "Завершить",
"first_name": "Имя",
"follow_these": "Выполните следующие действия",
"formbricks_version": "Версия Formbricks",
"full_name": "Полное имя",
@@ -245,9 +236,7 @@
"hidden": "Скрыто",
"hidden_field": "Скрытое поле",
"hidden_fields": "Скрытые поля",
"hide": "Скрыть",
"hide_column": "Скрыть столбец",
"id": "ID",
"image": "Изображение",
"images": "Изображения",
"import": "Импорт",
@@ -265,7 +254,6 @@
"key": "Ключ",
"label": "Метка",
"language": "Язык",
"last_name": "Фамилия",
"learn_more": "Подробнее",
"license_expired": "License Expired",
"light_overlay": "Светлый оверлей",
@@ -315,7 +303,6 @@
"on": "Вкл.",
"only_one_file_allowed": "Разрешён только один файл",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
"open_options": "Открыть параметры",
"option_id": "ID опции",
"option_ids": "ID опций",
"optional": "Необязательно",
@@ -441,7 +428,6 @@
"top_right": "Вверху справа",
"try_again": "Попробуйте ещё раз",
"type": "Тип",
"unknown_survey": "Неизвестный опрос",
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
"update": "Обновить",
"updated": "Обновлено",
@@ -458,7 +444,6 @@
"variables": "Переменные",
"verified_email": "Подтверждённый email",
"video": "Видео",
"view": "Просмотреть",
"warning": "Предупреждение",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
},
"analysis": {
"charts": {
"OR": "ИЛИ",
"add_chart_to_dashboard": "Добавить график на панель",
"add_chart_to_dashboard_description": "Выбери панель, чтобы добавить на неё этот график. График будет сохранён автоматически.",
"add_filter": "Добавить фильтр",
"add_to_dashboard": "Добавить на панель",
"advanced_chart_builder_config_prompt": "Настрой график и нажми «Выполнить запрос», чтобы посмотреть предварительный просмотр",
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
"ai_query_section_title": "Спроси свои данные",
"apply_changes": "Применить изменения",
"chart": "График",
"chart_added_to_dashboard": "График добавлен на панель!",
"chart_builder_choose_chart_type": "Выбери тип графика",
"chart_data": "Данные графика",
"chart_data_tab": "Данные",
"chart_deleted_successfully": "График успешно удалён",
"chart_deletion_error": "Не удалось удалить график",
"chart_duplicated_successfully": "График успешно дублирован",
"chart_duplication_error": "Не удалось дублировать график",
"chart_name": "Название графика",
"chart_name_placeholder": "Название графика",
"chart_preview": "Предпросмотр графика",
"chart_render_error": "Что-то пошло не так при отображении этой диаграммы.",
"chart_saved_successfully": "График успешно сохранён!",
"chart_type_area": "График областью",
"chart_type_bar": "Столбчатая диаграмма",
"chart_type_big_number": "Большое число",
"chart_type_line": "Линейный график",
"chart_type_not_supported": "Тип графика «{{chartType}}» пока не поддерживается",
"chart_type_pie": "Круговая диаграмма",
"chart_updated_successfully": "График успешно обновлён!",
"configure_description": "Измени тип графика и другие настройки этой визуализации.",
"configure_title": "Настроить график",
"configure_type_label": "Тип графика",
"contains": "содержит",
"create_chart": "Создать график",
"create_chart_description": "Используй ИИ для создания графика или собери его вручную.",
"custom_range": "Произвольный диапазон",
"dashboard": "Панель управления",
"dashboard_select_placeholder": "Выбери панель управления",
"data_label": "Данные",
"date_preset_last_30_days": "Последние 30 дней",
"date_preset_last_7_days": "Последние 7 дней",
"date_preset_last_month": "Прошлый месяц",
"date_preset_this_month": "В этом месяце",
"date_preset_this_quarter": "В этом квартале",
"date_preset_this_year": "В этом году",
"date_preset_today": "Сегодня",
"date_preset_yesterday": "Вчера",
"date_range": "Диапазон дат",
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
"dimensions": "Измерения",
"dimensions_toggle_description": "Группируй данные по категориям. Порядок важен для многомерных графиков.",
"edit_chart_description": "Просмотри и измени настройки своего графика.",
"edit_chart_title": "Редактировать график",
"enable_time_dimension": "Включить временное измерение",
"end_date": "Дата окончания",
"enter_a_name_for_your_chart": "Введи название для графика, чтобы сохранить его.",
"enter_value": "Введи значение",
"equals": "равно",
"failed_to_add_chart_to_dashboard": "Не удалось добавить график на панель управления",
"failed_to_execute_query": "Не удалось выполнить запрос",
"failed_to_load_chart": "Не удалось загрузить график",
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
"failed_to_save_chart": "Не удалось сохранить график",
"field": "Поле",
"field_label_average_score": "Средний балл",
"field_label_collected_at": "Дата сбора",
"field_label_count": "Количество",
"field_label_detractor_count": "Количество критиков",
"field_label_emotion": "Эмоция",
"field_label_field_type": "Тип поля",
"field_label_nps_score": "Оценка NPS",
"field_label_nps_value": "Значение NPS",
"field_label_passive_count": "Количество пассивных",
"field_label_promoter_count": "Количество промоутеров",
"field_label_response_id": "ID ответа",
"field_label_sentiment": "Тональность",
"field_label_source_name": "Название источника",
"field_label_source_type": "Тип источника",
"field_label_topic": "Тема",
"field_label_user_identifier": "Идентификатор пользователя",
"filters": "Фильтры",
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
"generate_chart": "Сгенерировать график",
"granularity": "Детализация",
"granularity_day": "День",
"granularity_hour": "Час",
"granularity_month": "Месяц",
"granularity_quarter": "Квартал",
"granularity_week": "Неделя",
"granularity_year": "Год",
"greater_than": "больше чем",
"greater_than_or_equal": "больше или равно",
"group_by": "Группировать по",
"group_by_description": "Выбери измерения для детализации данных. Порядок важен для многомерных графиков.",
"guide_button": "Открыть гайд по полям",
"guide_chart_type": "Тип графика",
"guide_chart_type_desc": "Как визуализируются данные: область, столбцы, линия, круговая диаграмма или большое число. Выбирай в зависимости от того, что хочешь показать (тренды, сравнения, части целого и т. д.).",
"guide_dimensions": "Измерения (Группировка)",
"guide_dimensions_desc": "Как ты разбиваешь или группируешь данные. Каждое измерение становится категорией на графике (например, настроение, тип источника, название опроса, канал, тема). Порядок важен для многомерных графиков.",
"guide_filters": "Фильтры",
"guide_filters_desc": "Условия, ограничивающие включаемые данные. Каждый фильтр состоит из поля, оператора (равно, содержит, больше чем и т. д.) и значений. И = все должны совпасть; Или = достаточно любого совпадения.",
"guide_measures": "Показатели (что считаешь или агрегируешь)",
"guide_measures_predefined": "Предустановленные показатели — это готовые метрики из твоих данных обратной связи: количество (всего ответов), количество промоутеров/детракторов/нейтралов (сегменты NPS), NPS-оценка, средний балл, процент завершения.",
"guide_quick_ref": "Быстрая справка",
"guide_term_dimension": "Категориальное поле для группировки или разбивки данных",
"guide_term_filter": "Условие, ограничивающее включаемые строки",
"guide_term_measure": "Числовое значение, которое агрегируется (count, сумма, среднее и т. д.)",
"guide_term_time": "Группировка по времени с выбором детализации и диапазона дат",
"guide_time_dimension": "Временное измерение",
"guide_time_dimension_desc": "Группировка по времени: выбери поле времени (обычно \"Собрано в\"), детализацию (час, день, неделя, месяц и т. д.) и диапазон дат (предустановленный или свой). Используй для анализа трендов во времени.",
"guide_title": "Путеводитель по конструктору графиков",
"is_not_set": "не задано",
"is_set": "задано",
"less_than": "меньше чем",
"less_than_or_equal": "меньше или равно",
"measures": "Показатели",
"no_charts_found": "Графики не найдены.",
"no_dashboards_available": "Нет доступных панелей управления",
"no_dashboards_create_first": "Сначала создай панель управления, чтобы добавить на неё графики.",
"no_data_available": "Нет доступных данных",
"no_data_returned": "Запрос не вернул данных",
"no_data_returned_for_chart": "Для графика не получено данных",
"no_grouping": "Нет (только фильтр)",
"no_valid_data_to_display": "Нет корректных данных для отображения",
"not_contains": "не содержит",
"not_equals": "не равно",
"open_chart": "Открыть график {{name}}",
"open_options": "Открыть настройки графика",
"or_filter_logic": "ИЛИ",
"original": "Оригинал",
"please_enter_chart_name": "Пожалуйста, введи название графика",
"please_select_at_least_one_measure": "Пожалуйста, выбери хотя бы один показатель",
"please_select_dashboard": "Пожалуйста, выбери панель управления",
"predefined_measures": "Предустановленные показатели",
"preset": "Пресет",
"query_executed_successfully": "Запрос успешно выполнен",
"reset_to_ai_suggestion": "Сбросить к предложению ИИ",
"save_chart": "Сохранить график",
"save_chart_dialog_title": "Сохранить график",
"select_dimensions": "Выберите измерения...",
"select_field": "Выберите поле",
"select_measures": "Выберите показатели...",
"select_preset": "Выберите пресет",
"showing_first_n_of": "Показаны первые {{n}} из {{count}} строк",
"start_date": "Дата начала",
"time_dimension": "Временное измерение",
"time_dimension_toggle_description": "Добавьте группировку по времени для анализа трендов."
},
"dashboards": {
"create_dashboard": "Создать панель управления",
"create_dashboard_description": "Введите название для новой панели управления.",
"create_failed": "Не удалось создать панель управления",
"create_success": "Панель управления успешно создана!",
"dashboard_name": "Название панели управления",
"dashboard_name_placeholder": "Моя панель управления",
"delete_confirmation": "Ты уверен, что хочешь удалить эту панель управления? Это действие нельзя отменить.",
"delete_failed": "Не удалось удалить панель управления",
"delete_success": "Панель управления успешно удалена",
"description_optional": "Описание (необязательно)",
"description_placeholder": "Описание панели управления",
"duplicate_failed": "Не удалось дублировать панель управления",
"duplicate_success": "Панель управления успешно продублирована!",
"no_dashboards_found": "Панели управления не найдены.",
"please_enter_name": "Пожалуйста, введите название панели управления"
}
},
"connect": {
"congrats": "Поздравляем!",
"connection_successful_message": "Отлично! Мы подключены.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Обновить контакты",
"contacts_table_refresh_success": "Контакты успешно обновлены",
"create_attribute": "Создать атрибут",
"create_key": "Создать ключ",
"create_new_attribute": "Создать новый атрибут",
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
"custom_attributes": "Пользовательские атрибуты",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {Будет удалён выбранный атрибут. Все данные контактов, связанные с этим атрибутом, будут потеряны.} few {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} many {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} other {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.}}",
"delete_contact_confirmation": "Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны. Если у этого контакта есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} few {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} many {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} other {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.}}",
"displays": "Показы",
"edit_attribute": "Редактировать атрибут",
"edit_attribute_description": "Обновите метку и описание для этого атрибута.",
"edit_attribute_values": "Редактировать атрибуты",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
"no_activity_yet": "Пока нет активности",
"no_published_link_surveys_available": "Нет доступных опубликованных опросов-ссылок. Пожалуйста, сначала опубликуйте опрос-ссылку.",
"no_published_surveys": "Нет опубликованных опросов",
"no_responses_found": "Ответы не найдены",
@@ -867,8 +681,6 @@
"select_a_survey": "Выберите опрос",
"select_attribute": "Выберите атрибут",
"select_attribute_key": "Выберите ключ атрибута",
"survey_viewed": "Опрос просмотрен",
"survey_viewed_at": "Просмотрено",
"system_attributes": "Системные атрибуты",
"unlock_contacts_description": "Управляйте контактами и отправляйте целевые опросы",
"unlock_contacts_title": "Откройте доступ к контактам с более высоким тарифом",
@@ -940,12 +752,7 @@
"link_google_sheet": "Связать с Google Sheet",
"link_new_sheet": "Связать с новой таблицей",
"no_integrations_yet": "Ваши интеграции с Google Sheet появятся здесь, как только вы их добавите. ⏲️",
"reconnect_button": "Переподключить",
"reconnect_button_description": "Срок действия подключения к Google Sheets истёк. Пожалуйста, переподключись, чтобы продолжить синхронизацию ответов. Все существующие ссылки на таблицы и данные будут сохранены.",
"reconnect_button_tooltip": "Переподключи интеграцию, чтобы обновить доступ. Все существующие ссылки на таблицы и данные будут сохранены.",
"spreadsheet_permission_error": "У тебя нет доступа к этой таблице. Убедись, что таблица открыта для твоего Google-аккаунта и у тебя есть права на запись.",
"spreadsheet_url": "URL таблицы",
"token_expired_error": "Срок действия токена обновления Google Sheets истёк или он был отозван. Пожалуйста, переподключи интеграцию."
"spreadsheet_url": "URL таблицы"
},
"include_created_at": "Включить дату создания",
"include_hidden_fields": "Включить скрытые поля",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Отфильтрованные ответы (Excel)",
"generating_qr_code": "Генерация QR-кода",
"impressions": "Просмотры",
"impressions_identified_only": "Показаны только показы от идентифицированных контактов",
"impressions_tooltip": "Количество раз, когда опрос был просмотрен.",
"in_app": {
"connection_description": "Опрос будет показан пользователям вашего сайта, которые соответствуют указанным ниже критериям",
@@ -2183,7 +1989,6 @@
"last_quarter": "Прошлый квартал",
"last_year": "Прошлый год",
"limit": "Лимит",
"no_identified_impressions": "Нет показов от идентифицированных контактов",
"no_responses_found": "Ответы не найдены",
"other_values_found": "Найдены другие значения",
"overall": "В целом",

View File

@@ -133,7 +133,6 @@
"allow": "Tillåt",
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
"analysis": "Analys",
"and": "Och",
"and_response_limit_of": "och svarsgräns på",
"anonymous": "Anonym",
@@ -150,8 +149,6 @@
"bottom_right": "Nedre höger",
"cancel": "Avbryt",
"centered_modal": "Centrerad modal",
"chart": "Diagram",
"charts": "Diagram",
"choices": "Val",
"choose_environment": "Välj miljö",
"choose_organization": "Välj organisation",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
"create": "Skapa",
"create_new_organization": "Skapa ny organisation",
"create_segment": "Skapa segment",
"create_survey": "Skapa enkät",
@@ -191,8 +187,6 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"dashboard": "Instrumentpanel",
"dashboards": "Instrumentpaneler",
"date": "Datum",
"days": "dagar",
"default": "Standard",
@@ -224,16 +218,13 @@
"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_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_title": "Begränsningsgräns överskriden",
"expand_rows": "Visa rader",
"failed_to_copy_to_clipboard": "Misslyckades att kopiera till urklipp",
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
"filter": "Filter",
"finish": "Slutför",
"first_name": "Förnamn",
"follow_these": "Följ dessa",
"formbricks_version": "Formbricks-version",
"full_name": "Fullständigt namn",
@@ -245,9 +236,7 @@
"hidden": "Dold",
"hidden_field": "Dolt fält",
"hidden_fields": "Dolda fält",
"hide": "Dölj",
"hide_column": "Dölj kolumn",
"id": "ID",
"image": "Bild",
"images": "Bilder",
"import": "Importera",
@@ -265,7 +254,6 @@
"key": "Nyckel",
"label": "Etikett",
"language": "Språk",
"last_name": "Efternamn",
"learn_more": "Läs mer",
"license_expired": "License Expired",
"light_overlay": "Ljust överlägg",
@@ -315,7 +303,6 @@
"on": "På",
"only_one_file_allowed": "Endast en fil är tillåten",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
"open_options": "Öppna alternativ",
"option_id": "Alternativ-ID",
"option_ids": "Alternativ-ID:n",
"optional": "Valfritt",
@@ -441,7 +428,6 @@
"top_right": "Övre höger",
"try_again": "Försök igen",
"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.",
"update": "Uppdatera",
"updated": "Uppdaterad",
@@ -458,7 +444,6 @@
"variables": "Variabler",
"verified_email": "Verifierad e-post",
"video": "Video",
"view": "Visa",
"warning": "Varning",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Vi kunde inte verifiera din licens eftersom licensservern inte kan nås.",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
},
"analysis": {
"charts": {
"OR": "ELLER",
"add_chart_to_dashboard": "Lägg till diagram på instrumentpanelen",
"add_chart_to_dashboard_description": "Välj en instrumentpanel att lägga till det här diagrammet på. Diagrammet sparas automatiskt.",
"add_filter": "Lägg till filter",
"add_to_dashboard": "Lägg till på instrumentpanelen",
"advanced_chart_builder_config_prompt": "Konfigurera ditt diagram och klicka på \"Kör fråga\" för att förhandsgranska",
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
"ai_query_section_title": "Fråga din data",
"apply_changes": "Verkställ ändringar",
"chart": "Diagram",
"chart_added_to_dashboard": "Diagram tillagt på instrumentpanelen!",
"chart_builder_choose_chart_type": "Välj diagramtyp",
"chart_data": "Diagramdata",
"chart_data_tab": "Data",
"chart_deleted_successfully": "Diagrammet har tagits bort",
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
"chart_duplicated_successfully": "Diagrammet har duplicerats",
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
"chart_name": "Diagramnamn",
"chart_name_placeholder": "Diagramnamn",
"chart_preview": "Förhandsgranska diagram",
"chart_render_error": "Något gick fel när diagrammet skulle visas.",
"chart_saved_successfully": "Diagram sparat!",
"chart_type_area": "Ytdiagram",
"chart_type_bar": "Stapeldiagram",
"chart_type_big_number": "Stort tal",
"chart_type_line": "Linjediagram",
"chart_type_not_supported": "Diagramtypen \"{{chartType}}\" stöds inte än",
"chart_type_pie": "Cirkeldiagram",
"chart_updated_successfully": "Diagram uppdaterat!",
"configure_description": "Ändra diagramtyp och andra inställningar för den här visualiseringen.",
"configure_title": "Konfigurera diagram",
"configure_type_label": "Diagramtyp",
"contains": "innehåller",
"create_chart": "Skapa diagram",
"create_chart_description": "Använd AI för att skapa ett diagram eller bygg ett manuellt.",
"custom_range": "Anpassat intervall",
"dashboard": "Instrumentpanel",
"dashboard_select_placeholder": "Välj en instrumentpanel",
"data_label": "Data",
"date_preset_last_30_days": "Senaste 30 dagarna",
"date_preset_last_7_days": "Senaste 7 dagarna",
"date_preset_last_month": "Senaste månaden",
"date_preset_this_month": "Denna månad",
"date_preset_this_quarter": "Detta kvartal",
"date_preset_this_year": "Detta år",
"date_preset_today": "Idag",
"date_preset_yesterday": "Igår",
"date_range": "Datumintervall",
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
"dimensions": "Dimensioner",
"dimensions_toggle_description": "Gruppera data efter kategorier. Ordningen är viktig för flerdimensionella diagram.",
"edit_chart_description": "Visa och redigera din diagramkonfiguration.",
"edit_chart_title": "Redigera diagram",
"enable_time_dimension": "Aktivera tidsdimension",
"end_date": "Slutdatum",
"enter_a_name_for_your_chart": "Ange ett namn för ditt diagram för att spara det.",
"enter_value": "Ange värde",
"equals": "är lika med",
"failed_to_add_chart_to_dashboard": "Det gick inte att lägga till diagrammet i instrumentpanelen",
"failed_to_execute_query": "Det gick inte att köra frågan",
"failed_to_load_chart": "Det gick inte att ladda diagrammet",
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
"failed_to_save_chart": "Det gick inte att spara diagrammet",
"field": "Fält",
"field_label_average_score": "Genomsnittligt betyg",
"field_label_collected_at": "Insamlad",
"field_label_count": "Antal",
"field_label_detractor_count": "Antal kritiker",
"field_label_emotion": "Känsla",
"field_label_field_type": "Fälttyp",
"field_label_nps_score": "NPS-poäng",
"field_label_nps_value": "NPS-värde",
"field_label_passive_count": "Antal passiva",
"field_label_promoter_count": "Antal förespråkare",
"field_label_response_id": "Svar-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Källnamn",
"field_label_source_type": "Källtyp",
"field_label_topic": "Ämne",
"field_label_user_identifier": "Användar-ID",
"filters": "Filter",
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
"generate_chart": "Generera diagram",
"granularity": "Detaljnivå",
"granularity_day": "Dag",
"granularity_hour": "timme",
"granularity_month": "månad",
"granularity_quarter": "kvartal",
"granularity_week": "vecka",
"granularity_year": "år",
"greater_than": "större än",
"greater_than_or_equal": "större än eller lika med",
"group_by": "Gruppera efter",
"group_by_description": "Välj dimensioner för att bryta ner din data. Ordningen är viktig för flerdimensionella diagram.",
"guide_button": "Visa fältguide",
"guide_chart_type": "Diagramtyp",
"guide_chart_type_desc": "Hur datan visualiseras: Yta, Stapel, Linje, Tårta eller Stort tal. Välj utifrån vad du vill visa (trender, jämförelser, delar av helhet, etc.).",
"guide_dimensions": "Dimensioner (Gruppera efter)",
"guide_dimensions_desc": "Hur du delar upp eller grupperar datan. Varje dimension blir en kategori i diagrammet (t.ex. Sentiment, Källtyp, Enkätnamn, Kanal, Ämne). Ordningen är viktig för flerdimensionella diagram.",
"guide_filters": "Filter",
"guide_filters_desc": "Villkor som begränsar vilken data som tas med. Varje filter har ett fält, en operator (lika med, innehåller, större än, etc.) och värden. Och = alla måste matcha; Eller = någon kan matcha.",
"guide_measures": "Mått (vad du räknar eller aggregerar)",
"guide_measures_predefined": "Fördefinierade mått är färdiga mätvärden från din feedbackdata: Antal (totala svar), Promotor/Detraktor/Passiv antal (NPS-segment), NPS-poäng, Medelpoäng, Slutförandegrad.",
"guide_quick_ref": "Snabbreferens",
"guide_term_dimension": "Kategorifält som används för att gruppera eller dela upp data",
"guide_term_filter": "Villkor som begränsar vilka rader som tas med",
"guide_term_measure": "Numeriskt värde du aggregerar (count, sum, avg, etc.)",
"guide_term_time": "Tidsbaserad gruppering med detaljnivå och datumintervall",
"guide_time_dimension": "Tidsdimension",
"guide_time_dimension_desc": "Tidsbaserad gruppering: välj ett tidsfält (oftast Insamlad vid), detaljnivå (timme, dag, vecka, månad osv.) och datumintervall (förinställt eller anpassat). Används för att visa trender över tid.",
"guide_title": "Fältguide för diagrambyggaren",
"is_not_set": "är inte satt",
"is_set": "är satt",
"less_than": "mindre än",
"less_than_or_equal": "mindre än eller lika med",
"measures": "Mått",
"no_charts_found": "Inga diagram hittades.",
"no_dashboards_available": "Inga instrumentpaneler tillgängliga",
"no_dashboards_create_first": "Skapa först en instrumentpanel för att lägga till diagram.",
"no_data_available": "Ingen data tillgänglig",
"no_data_returned": "Ingen data returnerades från frågan",
"no_data_returned_for_chart": "Ingen data returnerades för diagrammet",
"no_grouping": "Ingen (endast filter)",
"no_valid_data_to_display": "Ingen giltig data att visa",
"not_contains": "innehåller inte",
"not_equals": "är inte lika med",
"open_chart": "Öppna diagram {{name}}",
"open_options": "Öppna diagramalternativ",
"or_filter_logic": "ELLER",
"original": "Original",
"please_enter_chart_name": "Ange ett diagramnamn",
"please_select_at_least_one_measure": "Välj minst ett mått",
"please_select_dashboard": "Välj en instrumentpanel",
"predefined_measures": "Fördefinierade mått",
"preset": "Förinställning",
"query_executed_successfully": "Frågan kördes utan problem",
"reset_to_ai_suggestion": "Återställ till AI-förslag",
"save_chart": "Spara diagram",
"save_chart_dialog_title": "Spara diagram",
"select_dimensions": "Välj dimensioner...",
"select_field": "Välj fält",
"select_measures": "Välj mått...",
"select_preset": "Välj förinställning",
"showing_first_n_of": "Visar de första {{n}} av {{count}} raderna",
"start_date": "Startdatum",
"time_dimension": "Tidsdimension",
"time_dimension_toggle_description": "Lägg till tidsbaserad gruppering för trender över tid."
},
"dashboards": {
"create_dashboard": "Skapa instrumentpanel",
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
"create_failed": "Det gick inte att skapa instrumentpanelen",
"create_success": "Instrumentpanelen har skapats!",
"dashboard_name": "Instrumentpanelens namn",
"dashboard_name_placeholder": "Min instrumentpanel",
"delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
"delete_failed": "Det gick inte att ta bort instrumentpanelen",
"delete_success": "Instrumentpanelen har tagits bort",
"description_optional": "Beskrivning (valfritt)",
"description_placeholder": "Beskrivning av instrumentpanelen",
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
"duplicate_success": "Instrumentpanelen har duplicerats!",
"no_dashboards_found": "Inga instrumentpaneler hittades.",
"please_enter_name": "Ange ett namn på instrumentpanelen"
}
},
"connect": {
"congrats": "Grattis!",
"connection_successful_message": "Bra gjort! Vi är anslutna.",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "Uppdatera kontakter",
"contacts_table_refresh_success": "Kontakter uppdaterade",
"create_attribute": "Skapa attribut",
"create_key": "Skapa nyckel",
"create_new_attribute": "Skapa nytt attribut",
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
"custom_attributes": "Anpassade attribut",
@@ -840,7 +656,6 @@
"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_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_description": "Uppdatera etikett och beskrivning för detta attribut.",
"edit_attribute_values": "Redigera attribut",
@@ -852,7 +667,6 @@
"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_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_surveys": "Inga publicerade enkäter",
"no_responses_found": "Inga svar hittades",
@@ -867,8 +681,6 @@
"select_a_survey": "Välj en enkät",
"select_attribute": "Välj attribut",
"select_attribute_key": "Välj attributnyckel",
"survey_viewed": "Enkät visad",
"survey_viewed_at": "Visad kl.",
"system_attributes": "Systemattribut",
"unlock_contacts_description": "Hantera kontakter och skicka ut riktade enkäter",
"unlock_contacts_title": "Lås upp kontakter med en högre plan",
@@ -940,12 +752,7 @@
"link_google_sheet": "Länka Google 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. ⏲️",
"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."
"spreadsheet_url": "Kalkylblads-URL"
},
"include_created_at": "Inkludera Skapad vid",
"include_hidden_fields": "Inkludera dolda fält",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "Filtrerade svar (Excel)",
"generating_qr_code": "Genererar QR-kod",
"impressions": "Visningar",
"impressions_identified_only": "Visar bara visningar från identifierade kontakter",
"impressions_tooltip": "Antal gånger enkäten har visats.",
"in_app": {
"connection_description": "Enkäten kommer att visas för användare på din webbplats som matchar kriterierna nedan",
@@ -2183,7 +1989,6 @@
"last_quarter": "Senaste kvartalet",
"last_year": "Senaste året",
"limit": "Gräns",
"no_identified_impressions": "Inga visningar från identifierade kontakter",
"no_responses_found": "Inga svar hittades",
"other_values_found": "Andra värden hittades",
"overall": "Övergripande",

View File

@@ -133,7 +133,6 @@
"allow": "允许",
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
"analysis": "分析",
"and": "和",
"and_response_limit_of": "和 响应限制",
"anonymous": "匿名",
@@ -150,8 +149,6 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "居中 模态",
"chart": "图表",
"charts": "图表",
"choices": "选项",
"choose_environment": "选择 环境",
"choose_organization": "选择 组织",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
"count_contacts": "{value, plural, other {{value} 联系人} }",
"count_responses": "{value, plural, other {{value} 回复} }",
"create": "创建",
"create_new_organization": "创建 新的 组织",
"create_segment": "创建 细分",
"create_survey": "创建 调查",
@@ -191,8 +187,6 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"dashboard": "Dashboard",
"dashboards": "仪表盘",
"date": "日期",
"days": "天",
"default": "默认",
@@ -224,16 +218,13 @@
"error": "错误",
"error_component_description": "这个资源不存在或您没有权限访问它。",
"error_component_title": "错误 加载 资源",
"error_loading_data": "数据加载出错",
"error_rate_limit_description": "请求 达到 最大 上限 请 稍后 再试 。",
"error_rate_limit_title": "速率 限制 超过",
"expand_rows": "展开 行",
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_workspaces": "加载工作区失败",
"filter": "筛选",
"finish": "完成",
"first_name": "名字",
"follow_these": "遵循 这些",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -245,9 +236,7 @@
"hidden": "隐藏",
"hidden_field": "隐藏 字段",
"hidden_fields": "隐藏 字段",
"hide": "隐藏",
"hide_column": "隐藏 列",
"id": "ID",
"image": "图片",
"images": "图片",
"import": "导入",
@@ -265,7 +254,6 @@
"key": "键",
"label": "标签",
"language": "语言",
"last_name": "姓",
"learn_more": "了解 更多",
"license_expired": "License Expired",
"light_overlay": "浅色遮罩层",
@@ -315,7 +303,6 @@
"on": "开启",
"only_one_file_allowed": "只 允许 一个 文件",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
"open_options": "打开选项",
"option_id": "选项 ID",
"option_ids": "选项 ID",
"optional": "可选",
@@ -441,7 +428,6 @@
"top_right": "右上",
"try_again": "再试一次",
"type": "类型",
"unknown_survey": "未知调查",
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
"update": "更新",
"updated": "已更新",
@@ -458,7 +444,6 @@
"variables": "变量",
"verified_email": "已验证 电子邮件",
"video": "视频",
"view": "查看",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我们无法验证您的许可证,因为许可证服务器无法访问。",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
},
"analysis": {
"charts": {
"OR": "或",
"add_chart_to_dashboard": "添加图表到 Dashboard",
"add_chart_to_dashboard_description": "选择一个 Dashboard将此图表添加进去。图表会自动保存。",
"add_filter": "添加过滤器",
"add_to_dashboard": "添加到 Dashboard",
"advanced_chart_builder_config_prompt": "配置你的图表,然后点击“运行查询”预览",
"ai_query_placeholder": "例如:上周有多少用户注册?",
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
"ai_query_section_title": "向你的数据提问",
"apply_changes": "应用更改",
"chart": "图表",
"chart_added_to_dashboard": "图表已添加到 Dashboard",
"chart_builder_choose_chart_type": "选择图表类型",
"chart_data": "图表数据",
"chart_data_tab": "数据",
"chart_deleted_successfully": "图表删除成功",
"chart_deletion_error": "图表删除失败",
"chart_duplicated_successfully": "图表复制成功",
"chart_duplication_error": "图表复制失败",
"chart_name": "图表名称",
"chart_name_placeholder": "图表名称",
"chart_preview": "图表预览",
"chart_render_error": "渲染此图表时出现了问题。",
"chart_saved_successfully": "图表保存成功!",
"chart_type_area": "面积图",
"chart_type_bar": "柱状图",
"chart_type_big_number": "大数字",
"chart_type_line": "折线图",
"chart_type_not_supported": "暂不支持图表类型“{{chartType}}”",
"chart_type_pie": "饼图",
"chart_updated_successfully": "图表更新成功!",
"configure_description": "修改此可视化的图表类型和其他设置。",
"configure_title": "配置图表",
"configure_type_label": "图表类型",
"contains": "包含",
"create_chart": "创建图表",
"create_chart_description": "使用 AI 生成图表或手动创建。",
"custom_range": "自定义范围",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "请选择一个 Dashboard",
"data_label": "数据",
"date_preset_last_30_days": "最近 30 天",
"date_preset_last_7_days": "最近 7 天",
"date_preset_last_month": "上个月",
"date_preset_this_month": "本月",
"date_preset_this_quarter": "本季度",
"date_preset_this_year": "今年",
"date_preset_today": "今天",
"date_preset_yesterday": "昨天",
"date_range": "日期范围",
"delete_chart_confirmation": "你确定要删除这个图表吗?",
"dimensions": "维度",
"dimensions_toggle_description": "按类别分组数据。对于多维图表,顺序很重要。",
"edit_chart_description": "查看并编辑你的图表配置。",
"edit_chart_title": "编辑图表",
"enable_time_dimension": "启用时间维度",
"end_date": "结束日期",
"enter_a_name_for_your_chart": "请输入图表名称以保存。",
"enter_value": "输入值",
"equals": "等于",
"failed_to_add_chart_to_dashboard": "添加图表到 Dashboard 失败",
"failed_to_execute_query": "查询执行失败",
"failed_to_load_chart": "加载图表失败",
"failed_to_load_chart_data": "加载图表数据失败",
"failed_to_save_chart": "图表保存失败",
"field": "字段",
"field_label_average_score": "平均分",
"field_label_collected_at": "收集时间",
"field_label_count": "数量",
"field_label_detractor_count": "贬损者数量",
"field_label_emotion": "情感",
"field_label_field_type": "字段类型",
"field_label_nps_score": "NPS 得分",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者数量",
"field_label_promoter_count": "推荐者数量",
"field_label_response_id": "响应 ID",
"field_label_sentiment": "情绪",
"field_label_source_name": "来源名称",
"field_label_source_type": "来源类型",
"field_label_topic": "主题",
"field_label_user_identifier": "用户标识",
"filters": "筛选条件",
"filters_toggle_description": "仅包含符合以下条件的数据。",
"generate_chart": "生成图表",
"granularity": "粒度",
"granularity_day": "天",
"granularity_hour": "小时",
"granularity_month": "月",
"granularity_quarter": "季度",
"granularity_week": "周",
"granularity_year": "年",
"greater_than": "大于",
"greater_than_or_equal": "大于或等于",
"group_by": "分组依据",
"group_by_description": "选择用于细分数据的维度。对于多维图表,顺序很重要。",
"guide_button": "查看字段指南",
"guide_chart_type": "图表类型",
"guide_chart_type_desc": "数据的可视化方式:面积图、柱状图、折线图、饼图或大数字。根据你想展示的内容(趋势、对比、整体组成等)进行选择。",
"guide_dimensions": "维度(分组依据)",
"guide_dimensions_desc": "你如何拆分或分组数据。每个维度会成为图表上的一个类别(如情感、来源类型、问卷名称、渠道、主题)。对于多维图表,顺序很重要。",
"guide_filters": "筛选条件",
"guide_filters_desc": "限制包含哪些数据的条件。每个筛选包含字段、运算符等于、包含、大于等和数值。And = 全部满足Or = 满足任意一个即可。",
"guide_measures": "度量(你统计或汇总的内容)",
"guide_measures_predefined": "预设度量是从你的反馈数据中预先构建的指标:计数(总回复数)、推荐者/贬损者/中立者计数NPS分段、NPS得分、平均分、完成率。",
"guide_quick_ref": "快速参考",
"guide_term_dimension": "用于分组或拆分数据的分类字段",
"guide_term_filter": "限制包含哪些行的条件",
"guide_term_measure": "你要汇总的数值(计数、求和、平均等)",
"guide_term_time": "基于时间的分组,包含粒度和日期范围",
"guide_time_dimension": "时间维度",
"guide_time_dimension_desc": "基于时间的分组:选择一个时间字段(通常为“收集时间”),粒度(小时、天、周、月等)和日期范围(预设或自定义)。适用于查看随时间变化的趋势。",
"guide_title": "图表生成器使用指南",
"is_not_set": "未设置",
"is_set": "已设置",
"less_than": "小于",
"less_than_or_equal": "小于或等于",
"measures": "度量",
"no_charts_found": "未找到图表。",
"no_dashboards_available": "暂无可用 Dashboard",
"no_dashboards_create_first": "请先创建一个 Dashboard然后再添加图表。",
"no_data_available": "暂无数据",
"no_data_returned": "查询未返回数据",
"no_data_returned_for_chart": "该图表未返回数据",
"no_grouping": "无(仅筛选)",
"no_valid_data_to_display": "无有效数据可显示",
"not_contains": "不包含",
"not_equals": "不等于",
"open_chart": "打开图表 {{name}}",
"open_options": "打开图表选项",
"or_filter_logic": "或",
"original": "原始",
"please_enter_chart_name": "请输入图表名称",
"please_select_at_least_one_measure": "请至少选择一个度量",
"please_select_dashboard": "请选择一个 Dashboard",
"predefined_measures": "预设度量",
"preset": "预设",
"query_executed_successfully": "查询执行成功",
"reset_to_ai_suggestion": "重置为 AI 建议",
"save_chart": "保存图表",
"save_chart_dialog_title": "保存图表",
"select_dimensions": "选择维度...",
"select_field": "选择字段",
"select_measures": "选择度量...",
"select_preset": "选择预设",
"showing_first_n_of": "显示前 {{n}} 行,共 {{count}} 行",
"start_date": "开始日期",
"time_dimension": "时间维度",
"time_dimension_toggle_description": "添加基于时间的分组,用于趋势分析。"
},
"dashboards": {
"create_dashboard": "创建 Dashboard",
"create_dashboard_description": "请输入新 Dashboard 的名称。",
"create_failed": "创建 Dashboard 失败",
"create_success": "Dashboard 创建成功!",
"dashboard_name": "Dashboard 名称",
"dashboard_name_placeholder": "我的 Dashboard",
"delete_confirmation": "确定要删除此 Dashboard 吗?此操作无法撤销。",
"delete_failed": "删除 Dashboard 失败",
"delete_success": "Dashboard 删除成功",
"description_optional": "描述(可选)",
"description_placeholder": "Dashboard 描述",
"duplicate_failed": "复制 Dashboard 失败",
"duplicate_success": "Dashboard 复制成功!",
"no_dashboards_found": "未找到 Dashboard。",
"please_enter_name": "请输入 Dashboard 名称"
}
},
"connect": {
"congrats": "恭喜!",
"connection_successful_message": "做得好 !我们 已经 连接。",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "刷新 联系人",
"contacts_table_refresh_success": "联系人 已成功刷新",
"create_attribute": "创建属性",
"create_key": "创建键",
"create_new_attribute": "创建新属性",
"create_new_attribute_description": "为细分目的创建新属性。",
"custom_attributes": "自定义属性",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {这将删除所选属性。与该属性相关的任何联系人数据都将丢失。} other {这将删除所选属性。与这些属性相关的任何联系人数据都将丢失。}}",
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
"displays": "展示次数",
"edit_attribute": "编辑属性",
"edit_attribute_description": "更新此属性的标签和描述。",
"edit_attribute_values": "编辑属性",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
"invalid_date_format": "日期格式无效。请使用有效日期。",
"invalid_number_format": "数字格式无效。请输入有效的数字。",
"no_activity_yet": "暂无活动",
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",
"no_published_surveys": "没有已发布的调查",
"no_responses_found": "未找到 响应",
@@ -867,8 +681,6 @@
"select_a_survey": "选择一个调查",
"select_attribute": "选择 属性",
"select_attribute_key": "选择属性键",
"survey_viewed": "已查看调查",
"survey_viewed_at": "查看时间",
"system_attributes": "系统属性",
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
@@ -940,12 +752,7 @@
"link_google_sheet": "链接 Google 表格",
"link_new_sheet": "链接 新 表格",
"no_integrations_yet": "您的 Google Sheet 集成会在您 添加 后 出现在这里。 ⏲️",
"reconnect_button": "重新连接",
"reconnect_button_description": "你的 Google Sheets 连接已过期。请重新连接以继续同步回复。你现有的表格链接和数据会被保留。",
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的表格链接和数据会被保留。",
"spreadsheet_permission_error": "你没有权限访问此表格。请确保该表格已与你的 Google 账号共享,并且你拥有该表格的编辑权限。",
"spreadsheet_url": "电子表格 URL",
"token_expired_error": "Google Sheets 的刷新令牌已过期或被撤销。请重新连接集成。"
"spreadsheet_url": "电子表格 URL"
},
"include_created_at": "包括 创建 于",
"include_hidden_fields": "包括 隐藏 字段",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "过滤 反馈 Excel",
"generating_qr_code": "正在生成二维码",
"impressions": "印象",
"impressions_identified_only": "仅显示已识别联系人的展示次数",
"impressions_tooltip": "调查 被 查看 的 次数",
"in_app": {
"connection_description": "调查将显示给符合以下条件的您网站用户",
@@ -2183,7 +1989,6 @@
"last_quarter": "上季度",
"last_year": "去年",
"limit": "限额",
"no_identified_impressions": "没有已识别联系人的展示次数",
"no_responses_found": "未找到响应",
"other_values_found": "找到其他值",
"overall": "整体",

View File

@@ -133,7 +133,6 @@
"allow": "允許",
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
"analysis": "分析",
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
@@ -150,8 +149,6 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "置中彈窗",
"chart": "圖表",
"charts": "圖表",
"choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
@@ -181,7 +178,6 @@
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
"count_responses": "{value, plural, other {{value} 回應} }",
"create": "建立",
"create_new_organization": "建立新組織",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
@@ -191,8 +187,6 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"dashboard": "儀表板",
"dashboards": "儀表板",
"date": "日期",
"days": "天",
"default": "預設",
@@ -224,16 +218,13 @@
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
"error_component_title": "載入資源錯誤",
"error_loading_data": "載入資料時發生錯誤",
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_workspaces": "載入工作區失敗",
"filter": "篩選",
"finish": "完成",
"first_name": "名字",
"follow_these": "按照這些步驟",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -245,9 +236,7 @@
"hidden": "隱藏",
"hidden_field": "隱藏欄位",
"hidden_fields": "隱藏欄位",
"hide": "隱藏",
"hide_column": "隱藏欄位",
"id": "ID",
"image": "圖片",
"images": "圖片",
"import": "匯入",
@@ -265,7 +254,6 @@
"key": "金鑰",
"label": "標籤",
"language": "語言",
"last_name": "姓氏",
"learn_more": "瞭解更多",
"license_expired": "License Expired",
"light_overlay": "淺色覆蓋",
@@ -315,7 +303,6 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"open_options": "開啟選項",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"optional": "選填",
@@ -441,7 +428,6 @@
"top_right": "右上",
"try_again": "再試一次",
"type": "類型",
"unknown_survey": "未知問卷",
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
"update": "更新",
"updated": "已更新",
@@ -458,7 +444,6 @@
"variables": "變數",
"verified_email": "已驗證的電子郵件",
"video": "影片",
"view": "檢視",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我們無法驗證您的授權,因為授權伺服器無法連線。",
"webhook": "Webhook",
@@ -622,176 +607,6 @@
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
},
"analysis": {
"charts": {
"OR": "或",
"add_chart_to_dashboard": "新增圖表到儀表板",
"add_chart_to_dashboard_description": "請選擇一個儀表板來新增此圖表。圖表會自動儲存。",
"add_filter": "新增篩選器",
"add_to_dashboard": "新增到儀表板",
"advanced_chart_builder_config_prompt": "設定你的圖表,然後點擊「執行查詢」預覽",
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
"ai_query_section_title": "詢問你的數據",
"apply_changes": "套用變更",
"chart": "圖表",
"chart_added_to_dashboard": "圖表已新增到儀表板!",
"chart_builder_choose_chart_type": "選擇圖表類型",
"chart_data": "圖表資料",
"chart_data_tab": "資料",
"chart_deleted_successfully": "圖表已成功刪除",
"chart_deletion_error": "刪除圖表失敗",
"chart_duplicated_successfully": "圖表已成功複製",
"chart_duplication_error": "圖表複製失敗",
"chart_name": "圖表名稱",
"chart_name_placeholder": "圖表名稱",
"chart_preview": "圖表預覽",
"chart_render_error": "這個圖表在顯示時發生錯誤。",
"chart_saved_successfully": "圖表已成功儲存!",
"chart_type_area": "區域圖",
"chart_type_bar": "長條圖",
"chart_type_big_number": "大數字",
"chart_type_line": "折線圖",
"chart_type_not_supported": "尚未支援圖表類型「{{chartType}}」",
"chart_type_pie": "圓餅圖",
"chart_updated_successfully": "圖表已成功更新!",
"configure_description": "修改此視覺化的圖表類型及其他設定。",
"configure_title": "設定圖表",
"configure_type_label": "圖表類型",
"contains": "包含",
"create_chart": "建立圖表",
"create_chart_description": "使用 AI 產生圖表或手動建立。",
"custom_range": "自訂範圍",
"dashboard": "儀表板",
"dashboard_select_placeholder": "請選擇儀表板",
"data_label": "資料",
"date_preset_last_30_days": "過去 30 天",
"date_preset_last_7_days": "過去 7 天",
"date_preset_last_month": "上個月",
"date_preset_this_month": "本月",
"date_preset_this_quarter": "本季",
"date_preset_this_year": "今年",
"date_preset_today": "今天",
"date_preset_yesterday": "昨天",
"date_range": "日期範圍",
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
"dimensions": "維度",
"dimensions_toggle_description": "依類別分組資料。多維圖表時,順序會影響結果。",
"edit_chart_description": "檢視並編輯你的圖表設定。",
"edit_chart_title": "編輯圖表",
"enable_time_dimension": "啟用時間維度",
"end_date": "結束日期",
"enter_a_name_for_your_chart": "請輸入圖表名稱以儲存。",
"enter_value": "請輸入數值",
"equals": "等於",
"failed_to_add_chart_to_dashboard": "新增圖表到儀表板失敗",
"failed_to_execute_query": "查詢執行失敗",
"failed_to_load_chart": "載入圖表失敗",
"failed_to_load_chart_data": "載入圖表資料失敗",
"failed_to_save_chart": "儲存圖表失敗",
"field": "欄位",
"field_label_average_score": "平均分數",
"field_label_collected_at": "收集時間",
"field_label_count": "數量",
"field_label_detractor_count": "批評者數量",
"field_label_emotion": "情緒",
"field_label_field_type": "欄位類型",
"field_label_nps_score": "NPS 分數",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者數量",
"field_label_promoter_count": "推廣者數量",
"field_label_response_id": "回應 ID",
"field_label_sentiment": "情感",
"field_label_source_name": "來源名稱",
"field_label_source_type": "來源類型",
"field_label_topic": "主題",
"field_label_user_identifier": "使用者識別碼",
"filters": "篩選條件",
"filters_toggle_description": "只包含符合下列條件的資料。",
"generate_chart": "產生圖表",
"granularity": "粒度",
"granularity_day": "天",
"granularity_hour": "小時",
"granularity_month": "月",
"granularity_quarter": "季",
"granularity_week": "週",
"granularity_year": "年",
"greater_than": "大於",
"greater_than_or_equal": "大於或等於",
"group_by": "分組依據",
"group_by_description": "選擇要拆解資料的維度。多維度圖表時,順序會影響結果。",
"guide_button": "查看欄位指南",
"guide_chart_type": "圖表類型",
"guide_chart_type_desc": "資料的視覺化方式:區域圖、長條圖、折線圖、圓餅圖或大數字。請根據你想呈現的內容(趨勢、比較、整體組成等)選擇。",
"guide_dimensions": "維度(分組依據)",
"guide_dimensions_desc": "你如何拆分或分組資料。每個維度都會成為圖表上的一個類別(例如:情緒、來源類型、問卷名稱、渠道、主題)。多維度圖表時,順序很重要。",
"guide_filters": "篩選條件",
"guide_filters_desc": "限制納入哪些資料的條件。每個篩選包含欄位、運算子等於、包含、大於等和數值。And全部符合Or任一符合即可。",
"guide_measures": "指標(你要計算或彙總的內容)",
"guide_measures_predefined": "預設指標是從回饋資料中預先建立的指標計數總回應數、推廣者貶抑者中立者計數NPS 分群、NPS 分數、平均分數、完成率。",
"guide_quick_ref": "快速參考",
"guide_term_dimension": "用來分組或拆分資料的類別欄位",
"guide_term_filter": "限制納入哪些資料列的條件",
"guide_term_measure": "你要彙總的數值(計數、總和、平均等)",
"guide_term_time": "依時間分組,包含粒度與日期範圍",
"guide_time_dimension": "時間維度",
"guide_time_dimension_desc": "依時間分組:選擇一個時間欄位(通常是收集時間)、粒度(小時、天、週、月等)和日期範圍(預設或自訂)。適合用來觀察趨勢變化。",
"guide_title": "圖表建立指南",
"is_not_set": "未設定",
"is_set": "已設定",
"less_than": "小於",
"less_than_or_equal": "小於或等於",
"measures": "指標",
"no_charts_found": "找不到圖表。",
"no_dashboards_available": "沒有可用的儀表板",
"no_dashboards_create_first": "請先建立儀表板,才能新增圖表。",
"no_data_available": "沒有可用資料",
"no_data_returned": "查詢沒有回傳資料",
"no_data_returned_for_chart": "此圖表沒有回傳資料",
"no_grouping": "無(僅篩選)",
"no_valid_data_to_display": "沒有可顯示的有效資料",
"not_contains": "不包含",
"not_equals": "不等於",
"open_chart": "開啟圖表 {{name}}",
"open_options": "開啟圖表選項",
"or_filter_logic": "或",
"original": "原始",
"please_enter_chart_name": "請輸入圖表名稱",
"please_select_at_least_one_measure": "請至少選擇一個指標",
"please_select_dashboard": "請選擇一個儀表板",
"predefined_measures": "預設指標",
"preset": "預設",
"query_executed_successfully": "查詢執行成功",
"reset_to_ai_suggestion": "重設為 AI 建議",
"save_chart": "儲存圖表",
"save_chart_dialog_title": "儲存圖表",
"select_dimensions": "選擇維度...",
"select_field": "選擇欄位",
"select_measures": "選擇指標...",
"select_preset": "選擇預設",
"showing_first_n_of": "顯示前 {{n}} 筆,共 {{count}} 筆資料",
"start_date": "開始日期",
"time_dimension": "時間維度",
"time_dimension_toggle_description": "新增以時間為基礎的分組,方便觀察趨勢變化。"
},
"dashboards": {
"create_dashboard": "建立儀表板",
"create_dashboard_description": "請輸入新儀表板的名稱。",
"create_failed": "建立儀表板失敗",
"create_success": "儀表板建立成功!",
"dashboard_name": "儀表板名稱",
"dashboard_name_placeholder": "我的儀表板",
"delete_confirmation": "你確定要刪除此儀表板嗎?此操作無法復原。",
"delete_failed": "刪除儀表板失敗",
"delete_success": "儀表板刪除成功",
"description_optional": "描述(選填)",
"description_placeholder": "儀表板描述",
"duplicate_failed": "複製儀表板失敗",
"duplicate_success": "儀表板複製成功!",
"no_dashboards_found": "找不到儀表板。",
"please_enter_name": "請輸入儀表板名稱"
}
},
"connect": {
"congrats": "恭喜!",
"connection_successful_message": "做得好!我們已連線。",
@@ -830,6 +645,7 @@
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"create_attribute": "建立屬性",
"create_key": "建立金鑰",
"create_new_attribute": "建立新屬性",
"create_new_attribute_description": "建立新屬性以進行分群用途。",
"custom_attributes": "自訂屬性",
@@ -840,7 +656,6 @@
"delete_attribute_confirmation": "{value, plural, one {這將刪除所選屬性。與此屬性相關的聯絡人資料將會遺失。} other {這將刪除所選屬性。與這些屬性相關的聯絡人資料將會遺失。}}",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
"displays": "顯示次數",
"edit_attribute": "編輯屬性",
"edit_attribute_description": "更新此屬性的標籤與描述。",
"edit_attribute_values": "編輯屬性",
@@ -852,7 +667,6 @@
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
"invalid_date_format": "日期格式無效。請使用有效的日期。",
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
"no_activity_yet": "尚無活動",
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
"no_published_surveys": "沒有已發佈的問卷",
"no_responses_found": "找不到回應",
@@ -867,8 +681,6 @@
"select_a_survey": "選擇問卷",
"select_attribute": "選取屬性",
"select_attribute_key": "選取屬性鍵值",
"survey_viewed": "已查看問卷",
"survey_viewed_at": "查看時間",
"system_attributes": "系統屬性",
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
@@ -940,12 +752,7 @@
"link_google_sheet": "連結 Google 試算表",
"link_new_sheet": "連結新試算表",
"no_integrations_yet": "您的 Google 試算表整合將在您新增後立即顯示在此處。⏲️",
"reconnect_button": "重新連線",
"reconnect_button_description": "你的 Google Sheets 連線已過期。請重新連線以繼續同步回應。你現有的試算表連結和資料都會被保留。",
"reconnect_button_tooltip": "重新連線整合以刷新存取權限。你現有的試算表連結和資料都會被保留。",
"spreadsheet_permission_error": "你沒有權限存取這個試算表。請確認該試算表已與你的 Google 帳戶分享,且你擁有寫入權限。",
"spreadsheet_url": "試算表網址",
"token_expired_error": "Google Sheets 的刷新權杖已過期或被撤銷。請重新連線整合。"
"spreadsheet_url": "試算表網址"
},
"include_created_at": "包含建立於",
"include_hidden_fields": "包含隱藏欄位",
@@ -2140,7 +1947,6 @@
"filtered_responses_excel": "篩選回應 (Excel)",
"generating_qr_code": "正在生成 QR code",
"impressions": "曝光數",
"impressions_identified_only": "僅顯示已識別聯絡人的曝光次數",
"impressions_tooltip": "問卷已檢視的次數。",
"in_app": {
"connection_description": "調查將顯示給符合以下列出條件的網站用戶",
@@ -2183,7 +1989,6 @@
"last_quarter": "上一季",
"last_year": "去年",
"limit": "限制",
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
"no_responses_found": "找不到回應",
"other_values_found": "找到其他值",
"overall": "整體",

View File

@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", mockQuestions);
validateResponseData([], mockResponseData, "en", true, mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
});
test("should use default language code", () => {
@@ -125,58 +125,25 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only fields present in responseData", () => {
test("should validate only present fields when finished is false", () => {
const partialResponseData: TResponseData = { element1: "test" };
const elementsToValidate = [mockElements[0]];
const partialElements = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en");
validateResponseData(mockBlocks, partialResponseData, "en", false);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
});
test("should never validate elements not in responseData", () => {
const blocksWithTwoElements: TSurveyBlock[] = [
...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);
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
validateResponseData(mockBlocks, partialResponseData, "en", true);
// Only element1 should be validated, not element2 (even though it's required)
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
[allElements[0]],
responseDataWithOnlyElement1,
"en"
);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
});
});

View File

@@ -9,13 +9,13 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules.
* Only validates elements that have data in responseData - never validates
* all survey elements regardless of completion status.
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param responseData - Response data to validate (keyed by element ID)
* @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)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
@@ -23,6 +23,7 @@ export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -41,8 +42,11 @@ export const validateResponseData = (
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
// Always validate only elements that are present in responseData
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished
? allElements
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);

View File

@@ -12,9 +12,7 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs
| Prisma.DashboardFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};

View File

@@ -15,7 +15,7 @@ import {
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -51,10 +51,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
});
@@ -201,6 +198,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -246,10 +244,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
auditLog.newObject = response.data;
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
action: "updated",
targetType: "response",

View File

@@ -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 { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
@@ -44,9 +44,7 @@ export const GET = async (request: NextRequest) =>
environmentResponses.push(...res.data.data);
return responses.successResponse({
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
});
return responses.successResponse({ data: environmentResponses });
},
});
@@ -136,6 +134,7 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);

View File

@@ -5,7 +5,7 @@ import { Button } from "@/modules/ui/components/button";
export const BackToLoginButton = async () => {
const t = await getTranslate();
return (
<Button variant="default" className="w-full justify-center">
<Button variant="secondary" className="w-full justify-center">
<Link href="/auth/login" className="h-full w-full">
{t("auth.signup.log_in")}
</Link>

View File

@@ -1,43 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockLoad = vi.fn();
const mockTablePivot = vi.fn();
vi.mock("@cubejs-client/core", () => ({
default: vi.fn(() => ({
load: mockLoad,
})),
}));
describe("executeQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
const resultSet = { tablePivot: mockTablePivot };
mockLoad.mockResolvedValue(resultSet);
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
});
test("loads query and returns tablePivot result", async () => {
const { executeQuery } = await import("./cube-client");
const query = { measures: ["FeedbackRecords.count"] };
const result = await executeQuery(query);
expect(mockLoad).toHaveBeenCalledWith(query);
expect(mockTablePivot).toHaveBeenCalled();
expect(result).toEqual([{ id: "1", count: 42 }]);
});
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
const fullUrl = "https://cube.example.com/cubejs-api/v1";
vi.stubEnv("CUBEJS_API_URL", fullUrl);
const { executeQuery } = await import("./cube-client");
await executeQuery({ measures: ["FeedbackRecords.count"] });
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
vi.unstubAllEnvs();
});
});

View File

@@ -1,26 +0,0 @@
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
const getApiUrl = (): string => {
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
if (baseUrl.includes("/cubejs-api/v1")) {
return baseUrl;
}
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
};
let cubeClient: CubeApi | null = null;
function getCubeClient(): CubeApi {
if (!cubeClient) {
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
const token = process.env.CUBEJS_API_TOKEN ?? "";
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
}
return cubeClient;
}
export async function executeQuery(query: Query) {
const client = getCubeClient();
const resultSet = await client.load(query);
return resultSet.tablePivot();
}

View File

@@ -1,342 +0,0 @@
"use server";
import { createOpenAI } from "@ai-sdk/openai";
import { Output, generateText } from "ai";
import { z } from "zod";
import { type TChartQuery, ZChartQuery } from "@formbricks/types/analysis";
import { ZId } from "@formbricks/types/common";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
import { validateQueryMembers } from "@/modules/ee/analysis/charts/lib/chart-utils";
import {
createChart,
deleteChart,
duplicateChart,
getChart,
getCharts,
updateChart,
} from "@/modules/ee/analysis/charts/lib/charts";
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
/** Client-facing chart input (projectId and createdBy are resolved server-side) */
const ZChartCreateInputClient = ZChartCreateInput.omit({ projectId: true, createdBy: true });
const ZCreateChartAction = z.object({
environmentId: ZId,
chartInput: ZChartCreateInputClient,
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await createChart({
...parsedInput.chartInput,
projectId,
createdBy: ctx.user.id,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = chart.id;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
chartUpdateInput: ZChartUpdateInput,
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const { chart, updatedChart } = await updateChart(
parsedInput.chartId,
projectId,
parsedInput.chartUpdateInput
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const duplicatedChart = await duplicateChart(parsedInput.chartId, projectId, ctx.user.id);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
const ZDeleteChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
withAuditLogging(
"deleted",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await deleteChart(parsedInput.chartId, projectId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
return { success: true };
}
)
);
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getChart(parsedInput.chartId, projectId);
}
);
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartsAction>;
}) => {
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
const charts = await getCharts(parsedInput.environmentId);
return charts;
}
);
// ── Charts UI specific actions (query execution & AI generation) ─────────────
const ZExecuteQueryAction = z.object({
environmentId: ZId,
query: ZChartQuery,
});
export const executeQueryAction = authenticatedActionClient
.schema(ZExecuteQueryAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZExecuteQueryAction>;
}) => {
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
validateQueryMembers(parsedInput.query);
try {
return await executeQuery(parsedInput.query as Record<string, unknown>);
} catch (error) {
throw error instanceof Error ? error : new Error("Failed to execute query");
}
}
);
const CUBE_NAME = "FeedbackRecords";
const ZGenerateAIQueryResponse = z.object({
measures: z.array(z.string()),
dimensions: z.array(z.string()).nullable(),
timeDimensions: z
.array(
z.object({
dimension: z.string(),
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
dateRange: z.string().nullable(),
})
)
.nullable(),
chartType: ZChartType,
filters: z
.array(
z.object({
member: z.string(),
operator: z.enum([
"equals",
"notEquals",
"contains",
"notContains",
"set",
"notSet",
"gt",
"gte",
"lt",
"lte",
]),
values: z.array(z.string()).nullable(),
})
)
.nullable(),
});
const ZGenerateAIChartAction = z.object({
environmentId: ZId,
prompt: z.string().min(1).max(2000),
});
export const generateAIChartAction = authenticatedActionClient
.schema(ZGenerateAIChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGenerateAIChartAction>;
}) => {
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not configured");
}
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
const schemaContext = generateSchemaContext();
const { output } = await generateText({
model: openai("gpt-4o-mini"),
output: Output.object({ schema: ZGenerateAIQueryResponse }),
system: schemaContext,
prompt: `User request: "${parsedInput.prompt}"`,
});
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
const { chartType, ...cubeQuery } = { ...output, measures };
// Strip nulls/empty arrays so Cube.js receives only present fields
const cleanQuery: Record<string, unknown> = {
measures: cubeQuery.measures,
...(cubeQuery.dimensions?.length && { dimensions: cubeQuery.dimensions }),
...(cubeQuery.filters?.length && {
filters: cubeQuery.filters.map(({ member, operator, values }) => ({
member,
operator,
...(values != null && { values }),
})),
}),
...(cubeQuery.timeDimensions?.length && {
timeDimensions: cubeQuery.timeDimensions.map(({ dimension, granularity, dateRange }) => ({
dimension,
...(granularity != null && { granularity }),
...(dateRange != null && { dateRange }),
})),
}),
};
validateQueryMembers(cleanQuery as TChartQuery);
const data = await executeQuery(cleanQuery);
return {
query: cleanQuery,
chartType,
data: Array.isArray(data) ? data : [],
};
}
);

View File

@@ -1,113 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface AddToDashboardDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
dashboards: Array<{ id: string; name: string }>;
selectedDashboardId: string | undefined;
onDashboardSelect: (id: string) => void;
onConfirm: () => void;
isSaving: boolean;
}
export function AddToDashboardDialog({
isOpen,
onOpenChange,
chartName,
onChartNameChange,
dashboards,
selectedDashboardId,
onDashboardSelect,
onConfirm,
isSaving,
}: Readonly<AddToDashboardDialogProps>) {
const { t } = useTranslation();
return (
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && onOpenChange(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.add_chart_to_dashboard")}</DialogTitle>
<DialogDescription>
{t("environments.analysis.charts.add_chart_to_dashboard_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-4">
<div>
<Label htmlFor="chart-name">{t("environments.analysis.charts.chart_name")}</Label>
<Input
id="chart-name"
className="mt-2"
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
maxLength={255}
/>
</div>
<div>
<Label htmlFor="dashboard-select">{t("environments.analysis.charts.dashboard")}</Label>
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
<SelectTrigger
id="dashboard-select"
className="mt-2 w-full"
disabled={dashboards.length === 0}>
<SelectValue
placeholder={
dashboards.length === 0
? t("environments.analysis.charts.no_dashboards_available")
: t("environments.analysis.charts.dashboard_select_placeholder")
}
/>
</SelectTrigger>
<SelectContent position="popper" className="max-h-[200px]">
{dashboards.map((dashboard) => (
<SelectItem key={dashboard.id} value={dashboard.id}>
{dashboard.name}
</SelectItem>
))}
</SelectContent>
</Select>
{dashboards.length === 0 && (
<p className="mt-1 text-xs text-gray-500">
{t("environments.analysis.charts.no_dashboards_create_first")}
</p>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
{t("environments.analysis.charts.add_to_dashboard")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,246 +0,0 @@
"use client";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import type { TChartQuery } from "@formbricks/types/analysis";
import { AdvancedChartPreview } from "@/modules/ee/analysis/charts/components/advanced-chart-preview";
import { ChartTypeSelector } from "@/modules/ee/analysis/charts/components/chart-type-selector";
import { DimensionsPanel } from "@/modules/ee/analysis/charts/components/dimensions-panel";
import { FiltersPanel } from "@/modules/ee/analysis/charts/components/filters-panel";
import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures-panel";
import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel";
import { useChartQuery } from "@/modules/ee/analysis/charts/hooks/use-chart-query";
import {
type ChartBuilderState,
type FilterRow,
type TimeDimensionConfig,
buildCubeQuery,
parseQueryToState,
} from "@/modules/ee/analysis/lib/query-builder";
import { FEEDBACK_FIELDS } from "@/modules/ee/analysis/lib/schema-definition";
import type { AnalyticsResponse, TChartType } from "@/modules/ee/analysis/types/analysis";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface AdvancedChartBuilderProps {
environmentId: string;
chartType: TChartType;
initialQuery?: TChartQuery;
hidePreview?: boolean;
onChartGenerated?: (data: AnalyticsResponse) => void;
}
const ACTION = {
SET_MEASURES: "SET_MEASURES",
SET_DIMENSIONS: "SET_DIMENSIONS",
SET_FILTERS: "SET_FILTERS",
SET_FILTER_LOGIC: "SET_FILTER_LOGIC",
SET_TIME_DIMENSION: "SET_TIME_DIMENSION",
INIT_FROM_QUERY: "INIT_FROM_QUERY",
} as const;
type Action =
| { type: typeof ACTION.SET_MEASURES; payload: string[] }
| { type: typeof ACTION.SET_DIMENSIONS; payload: string[] }
| { type: typeof ACTION.SET_FILTERS; payload: FilterRow[] }
| { type: typeof ACTION.SET_FILTER_LOGIC; payload: "and" | "or" }
| { type: typeof ACTION.SET_TIME_DIMENSION; payload: TimeDimensionConfig | null }
| { type: typeof ACTION.INIT_FROM_QUERY; payload: Partial<ChartBuilderState> };
const initialState: ChartBuilderState = {
selectedMeasures: [],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBuilderState => {
switch (action.type) {
case ACTION.SET_MEASURES:
return { ...state, selectedMeasures: action.payload };
case ACTION.SET_DIMENSIONS:
return { ...state, selectedDimensions: action.payload };
case ACTION.SET_FILTERS:
return { ...state, filters: action.payload };
case ACTION.SET_FILTER_LOGIC:
return { ...state, filterLogic: action.payload };
case ACTION.SET_TIME_DIMENSION:
return { ...state, timeDimension: action.payload };
case ACTION.INIT_FROM_QUERY:
return { ...state, ...action.payload };
default:
return state;
}
};
export function AdvancedChartBuilder({
environmentId,
chartType,
initialQuery,
hidePreview = false,
onChartGenerated,
}: Readonly<AdvancedChartBuilderProps>) {
const { t } = useTranslation();
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
const [state, dispatch] = useReducer(
chartBuilderReducer,
initialQuery ? { ...initialState, ...parsedInitial } : initialState
);
const { chartData, query, isLoading, error, runQuery } = useChartQuery(environmentId, initialQuery);
const currentQuery = useMemo(() => buildCubeQuery(state), [state]);
const hasConfigChanged = useMemo(() => {
if (!query) return true;
return JSON.stringify(currentQuery) !== JSON.stringify(query);
}, [currentQuery, query]);
const appliedInitialQueryRef = useRef<TChartQuery | null>(null);
useEffect(() => {
if (!initialQuery) return;
if (appliedInitialQueryRef.current === initialQuery) return;
appliedInitialQueryRef.current = initialQuery;
const parsed = parseQueryToState(initialQuery);
dispatch({ type: ACTION.INIT_FROM_QUERY, payload: parsed });
setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0);
}, [initialQuery]);
const handleRunQuery = async () => {
if (state.selectedMeasures.length === 0) {
toast.error(t("environments.analysis.charts.please_select_at_least_one_measure"));
return;
}
const result = await runQuery(buildCubeQuery(state));
if (result) {
onChartGenerated?.({ ...result, chartType });
}
};
const [dimensionsOpen, setDimensionsOpen] = useState(
() => (parsedInitial?.selectedDimensions?.length ?? 0) > 0
);
const timeDimensionOpen = state.timeDimension != null;
const filtersOpen = state.filters.length > 0;
return (
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
<div className="mx-1 space-y-2">
{!hidePreview && (
<>
<ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />
</>
)}
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
<MeasuresPanel
selectedMeasures={state.selectedMeasures}
onMeasuresChange={(measures) => dispatch({ type: ACTION.SET_MEASURES, payload: measures })}
/>
</div>
<AdvancedOptionToggle
isChecked={dimensionsOpen}
onToggle={(checked) => {
setDimensionsOpen(checked);
if (!checked) dispatch({ type: ACTION.SET_DIMENSIONS, payload: [] });
}}
htmlId="chart-dimensions-toggle"
title={t("environments.analysis.charts.group_data")}
description={t("environments.analysis.charts.dimensions_toggle_description")}
customContainerClass="mt-2 px-0"
childrenContainerClass="flex-col gap-3 p-4"
childBorder>
<DimensionsPanel
hideTitle
selectedDimensions={state.selectedDimensions}
onDimensionsChange={(dimensions) =>
dispatch({ type: ACTION.SET_DIMENSIONS, payload: dimensions })
}
/>
</AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={timeDimensionOpen}
onToggle={() => {
if (timeDimensionOpen) dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: null });
else if (!state.timeDimension) {
dispatch({
type: ACTION.SET_TIME_DIMENSION,
payload: {
dimension: "FeedbackRecords.collectedAt",
dateRange: "last 30 days",
},
});
}
}}
htmlId="chart-time-dimension-toggle"
title={t("environments.analysis.charts.time_dimension_title")}
description={t("environments.analysis.charts.time_dimension_toggle_description")}
customContainerClass="mt-2 px-0"
childrenContainerClass="flex-col gap-3 p-4"
childBorder>
<TimeDimensionPanel
hideTitle
timeDimension={state.timeDimension}
onTimeDimensionChange={(config) => dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: config })}
/>
</AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={filtersOpen}
onToggle={() => {
if (filtersOpen) {
dispatch({ type: ACTION.SET_FILTERS, payload: [] });
} else if (state.filters.length === 0) {
const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0];
dispatch({
type: ACTION.SET_FILTERS,
payload: [
{
id: crypto.randomUUID(),
field: firstField?.id ?? "",
operator: "equals" as const,
values: null,
},
],
});
}
}}
htmlId="chart-filters-toggle"
title={t("environments.analysis.charts.filter_data")}
description={t("environments.analysis.charts.filters_toggle_description")}
customContainerClass="mt-2 px-0"
childrenContainerClass="flex-col gap-3 p-4"
childBorder>
<FiltersPanel
hideTitle
filters={state.filters}
filterLogic={state.filterLogic}
onFiltersChange={(filters) => dispatch({ type: ACTION.SET_FILTERS, payload: filters })}
onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })}
/>
</AdvancedOptionToggle>
<div className="flex justify-end">
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
{isLoading ? <LoadingSpinner /> : t("environments.analysis.charts.create_chart")}
</Button>
</div>
</div>
{!hidePreview && (
<AdvancedChartPreview
error={error}
isLoading={isLoading}
chartData={chartData}
chartType={chartType}
query={query}
/>
)}
</div>
);
}

View File

@@ -1,78 +0,0 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { DatabaseIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { TChartQuery } from "@formbricks/types/analysis";
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface AdvancedChartPreviewProps {
error: string | null;
isLoading: boolean;
chartData: TChartDataRow[] | null;
chartType: TChartType;
query: TChartQuery | null;
}
export function AdvancedChartPreview({
error,
isLoading,
chartData,
chartType,
query,
}: Readonly<AdvancedChartPreviewProps>) {
const { t } = useTranslation();
const [showData, setShowData] = useState(false);
const hasData = chartData && chartData.length > 0 && !isLoading && chartType && query;
const isEmpty = !chartData && !isLoading && !error;
return (
<div className="space-y-2">
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.chart_preview")}
</h3>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
)}
{isLoading && (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
)}
{hasData && (
<div className="space-y-2">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<ChartRenderer chartType={chartType} data={chartData} query={query} />
</div>
<Collapsible.Root open={showData} onOpenChange={setShowData}>
<Collapsible.CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<DatabaseIcon className="mr-2 h-4 w-4" />
{showData ? t("common.hide") : t("common.view")}{" "}
{t("environments.analysis.charts.data_label")}
</Button>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="mt-2">
<DataViewer data={chartData} />
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
)}
{isEmpty && (
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-500">
{t("environments.analysis.charts.advanced_chart_builder_config_prompt")}
</div>
)}
</div>
);
}

View File

@@ -1,82 +0,0 @@
"use client";
import { ActivityIcon, WandSparklesIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
interface AIQuerySectionProps {
environmentId: string;
onChartGenerated: (data: AnalyticsResponse) => void;
}
export function AIQuerySection({ environmentId, onChartGenerated }: Readonly<AIQuerySectionProps>) {
const [userQuery, setUserQuery] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { t } = useTranslation();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!userQuery.trim()) return;
setIsGenerating(true);
try {
const result = await generateAIChartAction({
environmentId,
prompt: userQuery.trim(),
});
if (result?.data) {
onChartGenerated(result.data);
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again");
toast.error(message);
} finally {
setIsGenerating(false);
}
};
return (
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
<ActivityIcon className="text-brand-dark h-5 w-5" />
</div>
<h2 className="font-semibold text-gray-900">
{t("environments.analysis.charts.ai_query_section_title")}
</h2>
<p className="text-sm text-gray-500">
{t("environments.analysis.charts.ai_query_section_description")}
</p>
</div>
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
<Input
autoFocus
placeholder={t("environments.analysis.charts.ai_query_placeholder")}
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
maxLength={2000}
disabled={isGenerating}
/>
<Button
type="submit"
variant="default"
disabled={!userQuery.trim() || isGenerating}
loading={isGenerating}>
<WandSparklesIcon className="h-4 w-4" />
{t("environments.analysis.charts.create_chart_with_ai")}
</Button>
</form>
</div>
);
}

View File

@@ -1,119 +0,0 @@
"use client";
import { type ElementType, type ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CartesianGrid, XAxis, YAxis } from "recharts";
import {
CHART_BRAND_DARK,
formatCellValue,
formatXAxisTick,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import type { ChartConfig } from "@/modules/ui/components/chart";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/modules/ui/components/chart";
const ChartTooltipRow = ({
value,
dataKey,
color,
}: Readonly<{ value: unknown; dataKey: string; color?: string }>) => {
const { t } = useTranslation();
const indicatorColor = color ?? CHART_BRAND_DARK;
return (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border border-current"
style={{
backgroundColor: indicatorColor,
borderColor: indicatorColor,
}}
/>
<div className="flex flex-1 items-center justify-between leading-none">
<span className="text-muted-foreground">{formatCubeColumnHeader(dataKey, t)}</span>
<span className="text-foreground font-mono font-medium tabular-nums">{formatCellValue(value)}</span>
</div>
</>
);
};
/** Creates a tooltip formatter bound to dataKey for Cartesian charts. Defined at module level to avoid Sonar "component in parent" warnings. */
const createTooltipFormatter = (dataKey: string) => {
const Formatter = (value: unknown) => <ChartTooltipRow value={value} dataKey={dataKey} />;
Formatter.displayName = "ChartTooltipFormatter";
return Formatter;
};
/** Tooltip content for single-measure Cartesian charts. */
const SingleMeasureTooltip = ({ dataKey }: Readonly<{ dataKey: string }>) => {
const formatter = useMemo(() => createTooltipFormatter(dataKey), [dataKey]);
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={formatter} />;
};
/** Tooltip formatter for multi-measure charts; uses each payload item's dataKey and color. */
const multiMeasureTooltipFormatter = (
value: unknown,
name: string,
item: { dataKey?: string; color?: string; payload?: { fill?: string } }
) => {
const key = item?.dataKey ?? name;
const color = item?.color ?? item?.payload?.fill;
return <ChartTooltipRow value={value} dataKey={key} color={color} />;
};
export interface CartesianChartProps {
data: TChartDataRow[];
xAxisKey: string;
dataKeys: string[];
chartConfig: ChartConfig;
chart: ElementType;
children: ReactNode;
showLegend?: boolean;
chartProps?: Record<string, unknown>;
}
/** Shared layout for bar, line, and area charts. Supports single or multiple measures. */
export function CartesianChart({
data,
xAxisKey,
dataKeys,
chartConfig,
chart: Chart,
children,
showLegend = false,
chartProps = {},
}: Readonly<CartesianChartProps>) {
const isMultiMeasure = dataKeys.length > 1;
const tooltipContent = isMultiMeasure ? (
<ChartTooltipContent labelFormatter={formatXAxisTick} formatter={multiMeasureTooltipFormatter} />
) : (
<SingleMeasureTooltip dataKey={dataKeys[0]} />
);
return (
<div className="h-64 w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<Chart data={data} {...chartProps}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisTick}
/>
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={tooltipContent} />
{showLegend && <ChartLegend content={<ChartLegendContent />} verticalAlign="top" height={36} />}
{children}
</Chart>
</ChartContainer>
</div>
);
}

View File

@@ -1,32 +0,0 @@
"use client";
import { PlusIcon, SaveIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { DialogFooter } from "@/modules/ui/components/dialog";
interface ChartDialogFooterProps {
onSaveClick: () => void;
onAddToDashboardClick: () => void;
isSaving: boolean;
}
export function ChartDialogFooter({
onSaveClick,
onAddToDashboardClick,
isSaving,
}: Readonly<ChartDialogFooterProps>) {
const { t } = useTranslation();
return (
<DialogFooter>
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.add_to_dashboard")}
</Button>
<Button onClick={onSaveClick} disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.save_chart")}
</Button>
</DialogFooter>
);
}

View File

@@ -1,25 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ChartDialogLoadingViewProps {
open: boolean;
onClose: () => void;
}
export function ChartDialogLoadingView({ open, onClose }: Readonly<ChartDialogLoadingViewProps>) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent width="wide">
<DialogTitle className="sr-only">{t("common.loading")}</DialogTitle>
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,129 +0,0 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChartWithCreator;
onEdit?: () => void;
}
export function ChartDropdownMenu({ environmentId, chart, onEdit }: Readonly<ChartDropdownMenuProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDeleteChart = async () => {
setIsDeleting(true);
try {
const result = await deleteChartAction({ environmentId, chartId: chart.id });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
setIsDeleteDialogOpen(false);
router.refresh();
} else {
const msg =
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_deletion_error");
toast.error(msg);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeleting(false);
}
};
const handleDuplicateChart = async () => {
setIsDuplicating(true);
try {
const result = await duplicateChartAction({ environmentId, chartId: chart.id });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_duplicated_successfully"));
router.refresh();
} else {
toast.error(
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_duplication_error")
);
}
} catch {
toast.error(t("environments.analysis.charts.chart_duplication_error"));
} finally {
setIsDuplicating(false);
}
};
return (
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild>
<Button variant="outline" className="px-2" onClick={(e) => e.stopPropagation()}>
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
<MoreVertical className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
<DropdownMenuGroup>
{onEdit && (
<DropdownMenuItem
icon={<SquarePenIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
onEdit();
}}>
{t("common.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem
icon={<CopyIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
handleDuplicateChart();
}}
disabled={isDuplicating}>
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<TrashIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
setIsDeleteDialogOpen(true);
}}
disabled={isDeleting}>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.chart")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteChart}
text={t("environments.analysis.charts.delete_chart_confirmation")}
isDeleting={isDeleting}
/>
</div>
);
}

View File

@@ -1,39 +0,0 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
interface ChartErrorBoundaryProps {
fallbackMessage: string;
children: ReactNode;
}
interface ChartErrorBoundaryState {
hasError: boolean;
}
export class ChartErrorBoundary extends Component<ChartErrorBoundaryProps, ChartErrorBoundaryState> {
constructor(props: ChartErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ChartErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ChartRenderer error:", error, info.componentStack);
}
override render() {
if (this.state.hasError) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center text-sm">
{this.props.fallbackMessage}
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,88 +0,0 @@
"use client";
import { BarChart, DatabaseIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChartErrorBoundary } from "@/modules/ee/analysis/charts/components/chart-error-boundary";
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer";
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
interface ChartPreviewProps {
chartData: AnalyticsResponse | null;
isLoading?: boolean;
error?: string | null;
}
export function ChartPreview({ chartData, isLoading = false, error }: Readonly<ChartPreviewProps>) {
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
const { t } = useTranslation();
const data = chartData?.data ?? [];
const handleTabChange = (value: string) => {
if (value === "chart" || value === "data") {
setActiveTab(value);
}
};
const renderContent = () => {
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (error || chartData?.error) {
return (
<div className="flex h-48 items-center justify-center text-sm text-red-600">
{error || chartData?.error}
</div>
);
}
if (!chartData) {
return (
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
return (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<div className="mb-4 flex justify-end">
<TabsList>
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
{t("environments.analysis.charts.chart")}
</TabsTrigger>
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
{t("environments.analysis.charts.chart_data_tab")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart" className="mt-0">
<ChartErrorBoundary fallbackMessage={t("environments.analysis.charts.chart_render_error")}>
<ChartRenderer chartType={chartData.chartType} data={data} query={chartData.query} />
</ChartErrorBoundary>
</TabsContent>
<TabsContent value="data" className="mt-0">
<DataViewer data={data} />
</TabsContent>
</Tabs>
);
};
return (
<div className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 font-semibold text-gray-900">{t("environments.analysis.charts.chart_preview")}</h3>
{renderContent()}
</div>
);
}

View File

@@ -1,202 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Area, AreaChart, Bar, BarChart, Cell, Line, LineChart, Pie, PieChart } from "recharts";
import type { TChartQuery } from "@formbricks/types/analysis";
import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesian-chart";
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
formatXAxisTick,
preparePieData,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
import type { ChartConfig } from "@/modules/ui/components/chart";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
const formatPieLabel = ({ name, percent }: { name: string; percent?: number }): string => {
if (percent == null) return "";
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
};
interface ChartRendererProps {
chartType: TChartType;
data: TChartDataRow[];
query: TChartQuery;
}
export function ChartRenderer({ chartType, data, query }: Readonly<ChartRendererProps>) {
const { t } = useTranslation();
if (!data || data.length === 0) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
const rowKeys = Object.keys(data[0] ?? {});
const timeDim = query.timeDimensions?.[0];
const timeDimKey = timeDim?.granularity
? `${timeDim.dimension}.${timeDim.granularity}`
: timeDim?.dimension;
const xAxisKey = query.dimensions?.[0] ?? timeDimKey ?? rowKeys[0] ?? "key";
const measureIds = query.measures?.filter((m) => rowKeys.includes(m)) ?? [];
const dataKeys = measureIds.length > 0 ? measureIds : rowKeys.filter((k) => k !== xAxisKey);
if (dataKeys.length === 0) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
const chartConfig: ChartConfig = Object.fromEntries(
dataKeys.map((key, i) => [
key,
{
label: formatCubeColumnHeader(key, t),
color: CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length],
},
])
);
const dataKey = dataKeys[0];
const isMultiMeasure = dataKeys.length > 1;
switch (chartType) {
case "bar":
return (
<CartesianChart
chart={BarChart}
data={data}
xAxisKey={xAxisKey}
dataKeys={dataKeys}
chartConfig={chartConfig}
showLegend
chartProps={isMultiMeasure ? { barCategoryGap: "20%" } : {}}>
{dataKeys.map((key, i) => (
<Bar
key={key}
dataKey={key}
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
radius={4}
/>
))}
</CartesianChart>
);
case "line":
return (
<CartesianChart
chart={LineChart}
data={data}
xAxisKey={xAxisKey}
dataKeys={dataKeys}
chartConfig={chartConfig}
showLegend={isMultiMeasure}>
{dataKeys.map((key, i) => {
const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length];
return (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={color}
strokeWidth={3}
dot={{ fill: color, r: 4 }}
activeDot={{ r: 6 }}
/>
);
})}
</CartesianChart>
);
case "area":
return (
<CartesianChart
chart={AreaChart}
data={data}
xAxisKey={xAxisKey}
dataKeys={dataKeys}
chartConfig={chartConfig}
showLegend={isMultiMeasure}>
{dataKeys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
fillOpacity={0.4}
strokeWidth={2}
/>
))}
</CartesianChart>
);
case "pie": {
const pieResult = preparePieData(data, dataKey);
if (!pieResult) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
{t("environments.analysis.charts.no_valid_data_to_display")}
</div>
);
}
const { processedData, colors } = pieResult;
return (
<div className="h-64 w-full min-w-0">
<ChartContainer config={chartConfig} className="h-full w-full min-w-0">
<PieChart>
<Pie
data={processedData}
dataKey={dataKey}
nameKey={xAxisKey}
cx="50%"
cy="50%"
outerRadius={80}
label={formatPieLabel}>
{processedData.map((row, index) => {
const rowKey = row[xAxisKey] ?? `row-${index}`;
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
})}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
/>
}
/>
</PieChart>
</ChartContainer>
</div>
);
}
case "big_number": {
const total =
data.length === 1
? Number(data[0]?.[dataKey]) || 0
: data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
const formatted = total.toLocaleString();
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="text-foreground text-4xl font-bold">{formatted}</div>
<div className="text-muted-foreground mt-2 text-sm">{formatCubeColumnHeader(dataKey, t)}</div>
</div>
</div>
);
}
default:
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
{t("environments.analysis.charts.chart_type_not_supported", { chartType })}
</div>
);
}
}

View File

@@ -1,97 +0,0 @@
"use client";
import { BarChart3Icon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { convertDateString, timeSinceDate } from "@/lib/time";
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartRowProps {
chart: TChartWithCreator;
environmentId: string;
isReadOnly: boolean;
}
export function ChartRow({ chart, environmentId, isReadOnly }: Readonly<ChartRowProps>) {
const { t } = useTranslation();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const IconComponent = CHART_TYPE_ICONS[chart.type] ?? BarChart3Icon;
const handleChartClick = () => {
if (!isReadOnly) {
setIsEditDialogOpen(true);
}
};
const handleRowKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleChartClick();
}
};
return (
<>
{/* Cannot use native <button>; row contains dropdown trigger (nested interactive invalid) */}
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-static-element-interactions */}
<div
role={isReadOnly ? undefined : "button"}
tabIndex={isReadOnly ? undefined : 0}
onClick={isReadOnly ? undefined : handleChartClick}
onKeyDown={isReadOnly ? undefined : handleRowKeyDown}
aria-label={
isReadOnly ? undefined : t("environments.analysis.charts.open_chart", { name: chart.name })
}
className={`grid h-12 w-full grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100 ${isReadOnly ? "" : "cursor-pointer"}`}>
<div className="col-span-6 grid grid-cols-6 content-center">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<IconComponent className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{convertDateString(chart.createdAt.toISOString())}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(new Date(chart.updatedAt))}</div>
</div>
</div>
<div // NOSONAR - stopPropagation wrapper to prevent row click when interacting with dropdown
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}>
{!isReadOnly && (
<ChartDropdownMenu
environmentId={environmentId}
chart={chart}
onEdit={() => setIsEditDialogOpen(true)}
/>
)}
</div>
</div>
{!isReadOnly && (
<CreateChartDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
environmentId={environmentId}
chartId={chart.id}
initialChart={chart}
onSuccess={() => setIsEditDialogOpen(false)}
/>
)}
</>
);
}

View File

@@ -1,47 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
interface ChartTypeSelectorProps {
selectedChartType: TChartType;
onChartTypeSelect: (chartType: TChartType) => void;
}
export function ChartTypeSelector({
selectedChartType,
onChartTypeSelect,
}: Readonly<ChartTypeSelectorProps>) {
const { t } = useTranslation();
const chartTypes = getChartTypes(t);
return (
<div className="space-y-2">
<h2 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.chart_builder_choose_chart_type")}
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{chartTypes.map((chart) => {
const isSelected = selectedChartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
}`}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,63 +0,0 @@
import { Delay } from "@suspensive/react";
import { Suspense, use } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
interface ChartsListContentProps {
chartsPromise: Promise<TChartWithCreator[]>;
environmentId: string;
isReadOnly: boolean;
}
const ChartsListContent = ({
chartsPromise,
environmentId,
isReadOnly,
}: Readonly<ChartsListContentProps>) => {
const charts = use(chartsPromise);
return <ChartsList charts={charts} environmentId={environmentId} isReadOnly={isReadOnly} />;
};
interface ChartsListPageProps {
environmentId: string;
}
export async function ChartsListPage({ environmentId }: Readonly<ChartsListPageProps>) {
const t = await getTranslate();
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
const chartsPromise = getChartsWithCreator(project.id);
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
environmentId={environmentId}
cta={isReadOnly ? undefined : <CreateChartButton environmentId={environmentId} />}>
<Suspense
fallback={
<Delay ms={200}>
<ChartsListSkeleton
columnHeaders={[
t("common.title"),
t("common.created_by"),
t("common.created_at"),
t("common.updated_at"),
]}
/>
</Delay>
}>
<ChartsListContent
chartsPromise={chartsPromise}
environmentId={environmentId}
isReadOnly={isReadOnly}
/>
</Suspense>
</AnalysisPageLayout>
);
}

View File

@@ -1,43 +0,0 @@
const SKELETON_ROWS = 3;
const SkeletonRow = () => {
return (
<div className="grid h-12 w-full animate-pulse grid-cols-7 content-center p-2">
<div className="col-span-3 flex items-center gap-4 pl-6">
<div className="h-5 w-5 rounded bg-gray-200" />
<div className="h-4 w-36 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-20 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-20 rounded bg-gray-200" />
</div>
<div className="col-span-1" />
</div>
);
};
interface ChartsListSkeletonProps {
columnHeaders: [string, string, string, string];
}
export const ChartsListSkeleton = ({ columnHeaders }: Readonly<ChartsListSkeletonProps>) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
<div className="col-span-1" />
</div>
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<SkeletonRow key={`skeleton-row-${String(i)}`} />
))}
</div>
);
};

View File

@@ -1,34 +0,0 @@
import { getTranslate } from "@/lingodotdev/server";
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartsListProps {
charts: TChartWithCreator[];
environmentId: string;
isReadOnly: boolean;
}
export const ChartsList = async ({ charts, environmentId, isReadOnly }: Readonly<ChartsListProps>) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
<div className="col-span-1" />
</div>
{charts.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">
{t("environments.analysis.charts.no_charts_found")}
</p>
) : (
charts.map((chart) => (
<ChartRow key={chart.id} chart={chart} environmentId={environmentId} isReadOnly={isReadOnly} />
))
)}
</div>
);
};

View File

@@ -1,99 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface ConfigureChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentChartType: TChartType;
configuredChartType: TChartType | null;
onChartTypeSelect: (type: TChartType) => void;
onReset: () => void;
}
export function ConfigureChartDialog({
open,
onOpenChange,
currentChartType,
configuredChartType,
onChartTypeSelect,
onReset,
}: Readonly<ConfigureChartDialogProps>) {
const { t } = useTranslation();
const chartTypes = getChartTypes(t);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.configure_title")}</DialogTitle>
<DialogDescription>{t("environments.analysis.charts.configure_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="space-y-6">
<div>
<h4 className="text-md mb-3 font-semibold text-gray-900">
{t("environments.analysis.charts.configure_type_label")}
</h4>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{chartTypes.map((chart) => {
const isSelected = (configuredChartType || currentChartType) === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
isSelected
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
: "border-gray-200"
)}
aria-label={chart.label}>
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
</button>
);
})}
</div>
<div className="mt-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onReset} className="text-sm">
{t("environments.analysis.charts.reset_to_ai_suggestion")}
</Button>
{configuredChartType && (
<span className="text-sm text-gray-500">
{t("environments.analysis.charts.original")}:{" "}
{chartTypes.find((c) => c.id === currentChartType)?.label ?? currentChartType}
</span>
)}
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.close")}
</Button>
<Button onClick={() => onOpenChange(false)}>
{t("environments.analysis.charts.apply_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,26 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { Button } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
environmentId: string;
}
export function CreateChartButton({ environmentId }: Readonly<CreateChartButtonProps>) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button size="sm" onClick={() => setIsDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.create_chart")}
</Button>
<CreateChartDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} environmentId={environmentId} />
</>
);
}

View File

@@ -1,45 +0,0 @@
"use client";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
}
export function CreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
}: Readonly<CreateChartDialogProps>) {
if (chartId) {
return (
<EditChartView
open={open}
onOpenChange={onOpenChange}
environmentId={environmentId}
chartId={chartId}
initialChart={initialChart}
onSuccess={onSuccess}
/>
);
}
return (
<CreateChartView
open={open}
onOpenChange={onOpenChange}
environmentId={environmentId}
onSuccess={onSuccess}
/>
);
}

View File

@@ -1,141 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface CreateChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
onSuccess?: () => void;
}
export function CreateChartView({
open,
onOpenChange,
environmentId,
onSuccess,
}: Readonly<CreateChartViewProps>) {
const { t } = useTranslation();
const {
chartData,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
handleClose,
} = useChartDialog({ open, onOpenChange, environmentId, onSuccess });
const chartPreviewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (chartData) {
chartPreviewRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [chartData]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide" disableCloseOnOutsideClick>
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.create_chart")}</DialogTitle>
<DialogDescription>{t("environments.analysis.charts.create_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
<AIQuerySection environmentId={environmentId} onChartGenerated={handleChartGenerated} />
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">
{t("environments.analysis.charts.OR")}
</span>
</div>
</div>
<ManualChartBuilder
selectedChartType={selectedChartType}
onChartTypeSelect={handleChartTypeChange}
/>
{selectedChartType && (
<AdvancedChartBuilder
environmentId={environmentId}
chartType={selectedChartType}
initialQuery={chartData?.query}
hidePreview={true}
onChartGenerated={handleChartGenerated}
/>
)}
{chartData && (
<div ref={chartPreviewRef}>
<ChartPreview chartData={chartData} />
</div>
)}
</div>
</DialogBody>
{chartData && (
<>
<ChartDialogFooter
onSaveClick={() => setIsSaveDialogOpen(true)}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,76 +0,0 @@
"use client";
import { DatabaseIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatCellValue } from "@/modules/ee/analysis/charts/lib/chart-utils";
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
const MAX_DISPLAY_ROWS = 50;
interface DataViewerProps {
data: TChartDataRow[];
}
export function DataViewer({ data }: Readonly<DataViewerProps>) {
const { t } = useTranslation();
if (!data || data.length === 0 || Object.keys(data[0]).length === 0) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p className="text-sm text-gray-500">{t("environments.analysis.charts.no_data_available")}</p>
</div>
);
}
const columns = Object.keys(data[0]);
const displayData = data.slice(0, MAX_DISPLAY_ROWS);
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-2 flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-gray-600" />
<h4 className="text-sm font-semibold text-gray-900">
{t("environments.analysis.charts.chart_data")}
</h4>
</div>
<div className="max-h-64 overflow-auto rounded bg-white">
<table className="w-full text-xs">
<thead className="bg-gray-100">
<tr>
{columns.map((key) => (
<th
key={key}
scope="col"
className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
{formatCubeColumnHeader(key, t)}
</th>
))}
</tr>
</thead>
<tbody>
{displayData.map((row, index) => {
const firstValue = Object.values(row)[0];
const rowKey = firstValue ? String(firstValue) : `row-${index}`;
return (
<tr key={`data-row-${rowKey}-${index}`} className="border-b border-gray-100 hover:bg-gray-50">
{Object.entries(row).map(([key, value]) => (
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
{formatCellValue(value)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
{data.length > MAX_DISPLAY_ROWS && (
<div className="px-3 py-2 text-xs text-gray-500">
{t("environments.analysis.charts.showing_first_n_of", {
n: MAX_DISPLAY_ROWS,
count: data.length,
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { FEEDBACK_FIELDS, getTranslatedFieldLabel } from "@/modules/ee/analysis/lib/schema-definition";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface DimensionsPanelProps {
selectedDimensions: string[];
onDimensionsChange: (dimensions: string[]) => void;
hideTitle?: boolean;
}
export function DimensionsPanel({
selectedDimensions,
onDimensionsChange,
hideTitle = false,
}: Readonly<DimensionsPanelProps>) {
const { t } = useTranslation();
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: [getTranslatedFieldLabel(d.id, t), d.description].filter(Boolean).join(" - "),
}));
return (
<div className="w-full space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.dimensions")}
</h3>
)}
<div className="space-y-2">
<Label className="text-sm">{t("environments.analysis.charts.group_by")}</Label>
<MultiSelect
options={dimensionOptions}
value={selectedDimensions}
onChange={onDimensionsChange}
placeholder={t("environments.analysis.charts.select_dimensions")}
/>
<Alert variant="info" size="small">
<AlertTitle>{t("environments.analysis.charts.group_by_description")}</AlertTitle>
</Alert>
</div>
</div>
);
}

View File

@@ -1,144 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
interface EditChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
}
export function EditChartView({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
}: Readonly<EditChartViewProps>) {
const { t } = useTranslation();
const {
chartData,
initialQuery,
isLoadingChart,
chartLoadError,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
handleClose,
} = useChartDialog({ open, onOpenChange, environmentId, chartId, initialChart, onSuccess });
if (isLoadingChart && !initialChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (!isLoadingChart && !chartData && !initialChart && chartLoadError) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent width="wide">
<DialogHeader>
<DialogTitle>{t("common.error")}</DialogTitle>
<DialogDescription />
</DialogHeader>
<DialogBody>
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-sm text-red-600">{chartLoadError}</p>
<Button variant="outline" onClick={handleClose}>
{t("common.close")}
</Button>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
const chartType = selectedChartType ?? DEFAULT_CHART_TYPE;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.edit_chart_title")}</DialogTitle>
<DialogDescription>{t("environments.analysis.charts.edit_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4 px-1">
<div className="space-y-2">
<label htmlFor="edit-chart-name" className="text-sm">
{t("environments.analysis.charts.chart_name")}
</label>
<Input
id="edit-chart-name"
value={chartName}
onChange={(e) => setChartName(e.target.value)}
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
className="w-full"
/>
</div>
<div className="space-y-2">
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
</div>
<AdvancedChartBuilder
environmentId={environmentId}
chartType={chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={handleChartGenerated}
/>
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
</DialogBody>
<ChartDialogFooter
onSaveClick={handleSaveChart}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,217 +0,0 @@
"use client";
import { Plus, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder";
import {
FEEDBACK_FIELDS,
getFieldById,
getFilterOperatorsForType,
getTranslatedFieldLabel,
} from "@/modules/ee/analysis/lib/schema-definition";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface FiltersPanelProps {
filters: FilterRow[];
filterLogic: "and" | "or";
onFiltersChange: (filters: FilterRow[]) => void;
onFilterLogicChange: (logic: "and" | "or") => void;
hideTitle?: boolean;
}
export function FiltersPanel({
filters,
filterLogic,
onFiltersChange,
onFilterLogicChange,
hideTitle = false,
}: Readonly<FiltersPanelProps>) {
const { t } = useTranslation();
const fieldOptions = [
...FEEDBACK_FIELDS.dimensions.map((d) => ({
value: d.id,
label: getTranslatedFieldLabel(d.id, t),
type: d.type,
})),
...FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: getTranslatedFieldLabel(m.id, t),
type: "number" as TFilterFieldType,
})),
];
const handleAddFilter = () => {
const firstField = fieldOptions[0];
onFiltersChange([
...filters,
{
id: crypto.randomUUID(),
field: firstField?.value || "",
operator: "equals",
values: null,
},
]);
};
const handleRemoveFilter = (index: number) => {
onFiltersChange(filters.filter((_, i) => i !== index));
};
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...updates };
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
updated[index].values = null;
}
onFiltersChange(updated);
};
const getValueInput = (filter: FilterRow, index: number) => {
const field = getFieldById(filter.field);
const fieldType = (field?.type || "string") as TFilterFieldType;
if (filter.operator === "set" || filter.operator === "notSet") {
return null;
}
const isNumericInput =
fieldType === "number" &&
(filter.operator === "gt" ||
filter.operator === "gte" ||
filter.operator === "lt" ||
filter.operator === "lte");
return (
<Input
type={isNumericInput ? "number" : "text"}
placeholder={t("environments.analysis.charts.enter_value")}
value={filter.values?.[0] ?? ""}
onChange={(e) => {
let values: string[] | number[] | null = null;
if (e.target.value) {
values = isNumericInput ? [Number(e.target.value)] : [e.target.value];
}
handleUpdateFilter(index, { values });
}}
className={isNumericInput ? "w-[150px] bg-white" : "w-[200px] bg-white"}
/>
);
};
const hasFilters = filters.length > 0;
const hasMultipleFilters = filters.length > 1;
return (
<div className="w-full space-y-2">
{hasMultipleFilters && (
<div className={`flex items-center ${hideTitle ? "justify-start" : "justify-between"}`}>
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.filters")}
</h3>
)}
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
<SelectTrigger className="w-[100px] bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="and">{t("environments.analysis.charts.and_filter_logic")}</SelectItem>
<SelectItem value="or">{t("environments.analysis.charts.or_filter_logic")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2 rounded-lg border border-gray-200 bg-white p-3">
{filters.map((filter, index) => {
const field = getFieldById(filter.field);
const fieldType = (field?.type || "string") as "string" | "number" | "time";
const operators = getFilterOperatorsForType(fieldType);
return (
<div key={filter.id} className="flex items-center gap-2">
<Select
value={filter.field}
onValueChange={(value) => {
const newField = getFieldById(value);
const newType = (newField?.type || "string") as TFilterFieldType;
const newOperators = getFilterOperatorsForType(newType);
handleUpdateFilter(index, {
field: value,
operator: newOperators[0] || "equals",
values: null,
});
}}>
<SelectTrigger className="w-[200px] bg-white">
<SelectValue placeholder={t("environments.analysis.charts.select_field")} />
</SelectTrigger>
<SelectContent>
{fieldOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(value) =>
handleUpdateFilter(index, {
operator: value,
})
}>
<SelectTrigger className="w-[150px] bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op} value={op}>
{op === "equals" && t("environments.analysis.charts.equals")}
{op === "notEquals" && t("environments.analysis.charts.not_equals")}
{op === "contains" && t("environments.analysis.charts.contains")}
{op === "notContains" && t("environments.analysis.charts.not_contains")}
{op === "set" && t("environments.analysis.charts.is_set")}
{op === "notSet" && t("environments.analysis.charts.is_not_set")}
{op === "gt" && t("environments.analysis.charts.greater_than")}
{op === "gte" && t("environments.analysis.charts.greater_than_or_equal")}
{op === "lt" && t("environments.analysis.charts.less_than")}
{op === "lte" && t("environments.analysis.charts.less_than_or_equal")}
</SelectItem>
))}
</SelectContent>
</Select>
{getValueInput(filter, index)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveFilter(index)}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
);
})}
{hasFilters && (
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
<Plus className="h-4 w-4" />
{t("environments.analysis.charts.add_filter")}
</Button>
)}
</div>
</div>
);
}

View File

@@ -1,47 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
interface ManualChartBuilderProps {
selectedChartType?: TChartType;
onChartTypeSelect: (type: TChartType) => void;
}
export function ManualChartBuilder({
selectedChartType,
onChartTypeSelect,
}: Readonly<ManualChartBuilderProps>) {
const { t } = useTranslation();
const chartTypes = getChartTypes(t);
return (
<div className="space-y-2">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="grid grid-cols-5 gap-4">
{chartTypes.map((chart) => {
const isSelected = selectedChartType === chart.id;
return (
<button
key={chart.id}
type="button"
onClick={() => onChartTypeSelect(chart.id)}
className={cn(
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
isSelected
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
: "border-gray-200 hover:border-gray-300"
)}>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
</div>
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,41 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { FEEDBACK_FIELDS, getTranslatedFieldLabel } from "@/modules/ee/analysis/lib/schema-definition";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface MeasuresPanelProps {
selectedMeasures: string[];
onMeasuresChange: (measures: string[]) => void;
hideTitle?: boolean;
}
export function MeasuresPanel({
selectedMeasures,
onMeasuresChange,
hideTitle = false,
}: Readonly<MeasuresPanelProps>) {
const { t } = useTranslation();
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: [getTranslatedFieldLabel(m.id, t), m.description].filter(Boolean).join(" - "),
}));
return (
<div className="w-full space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">{t("environments.analysis.charts.measures")}</h3>
)}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.predefined_measures")}</label>
<MultiSelect
options={measureOptions}
value={selectedMeasures}
onChange={(selected) => onMeasuresChange(selected)}
placeholder={t("environments.analysis.charts.select_measures")}
/>
</div>
</div>
);
}

View File

@@ -1,71 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
interface SaveChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chartName: string;
onChartNameChange: (name: string) => void;
onSave: () => void;
isSaving: boolean;
}
export function SaveChartDialog({
open,
onOpenChange,
chartName,
onChartNameChange,
onSave,
isSaving,
}: Readonly<SaveChartDialogProps>) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.save_chart_dialog_title")}</DialogTitle>
<DialogDescription>
{t("environments.analysis.charts.enter_a_name_for_your_chart")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<label htmlFor="save-chart-name" className="sr-only">
{t("environments.analysis.charts.chart_name")}
</label>
<Input
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
maxLength={255}
onKeyDown={(e) => {
if (e.key === "Enter" && chartName.trim() && !isSaving) {
onSave();
}
}}
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,244 +0,0 @@
"use client";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { useState } from "react";
import Calendar from "react-calendar";
import { useTranslation } from "react-i18next";
import type { TimeDimensionConfig } from "@/modules/ee/analysis/lib/query-builder";
import {
DATE_PRESETS,
FEEDBACK_FIELDS,
TIME_GRANULARITIES,
getTranslatedDatePresetLabel,
getTranslatedFieldLabel,
getTranslatedGranularityLabel,
} from "@/modules/ee/analysis/lib/schema-definition";
import { Button } from "@/modules/ui/components/button";
import "@/modules/ui/components/date-picker/styles.css";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
const TIME_FIELD_OPTIONS = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
interface TimeDimensionPanelProps {
timeDimension: TimeDimensionConfig | null;
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
hideTitle?: boolean;
}
export function TimeDimensionPanel({
timeDimension,
onTimeDimensionChange,
hideTitle = false,
}: Readonly<TimeDimensionPanelProps>) {
const { t } = useTranslation();
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
);
const [customStartDate, setCustomStartDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
);
const [customEndDate, setCustomEndDate] = useState<Date | null>(
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
);
const [presetValue, setPresetValue] = useState<string>(
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
);
const handleEnableTimeDimension = () => {
if (!timeDimension) {
onTimeDimensionChange({
dimension: "FeedbackRecords.collectedAt",
dateRange: "last 30 days",
});
setPresetValue("last 30 days");
setDateRangeType("preset");
}
};
const handleDimensionChange = (dimension: string) => {
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dimension });
}
};
const handleGranularityChange = (value: string) => {
if (timeDimension) {
const granularity = value === "none" ? undefined : (value as TimeDimensionConfig["granularity"]);
onTimeDimensionChange({ ...timeDimension, granularity });
}
};
const handlePresetChange = (preset: string) => {
setPresetValue(preset);
if (timeDimension) {
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
}
};
if (!timeDimension) {
return (
<div className="space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.time_dimension")}
</h3>
)}
<div>
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
{t("environments.analysis.charts.enable_time_dimension")}
</Button>
</div>
</div>
);
}
return (
<div className="w-full space-y-2">
{!hideTitle && (
<h3 className="text-md font-semibold text-gray-900">
{t("environments.analysis.charts.time_dimension")}
</h3>
)}
<div className="space-y-3">
{/* Field Selector */}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.field")}</label>
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_FIELD_OPTIONS.map((field) => (
<SelectItem key={field.id} value={field.id}>
{getTranslatedFieldLabel(field.id, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Granularity Selector */}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.granularity")}</label>
<Select value={timeDimension.granularity ?? "none"} onValueChange={handleGranularityChange}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("environments.analysis.charts.no_grouping")}</SelectItem>
{TIME_GRANULARITIES.map((gran) => (
<SelectItem key={gran} value={gran}>
{getTranslatedGranularityLabel(gran, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Date Range */}
<div className="space-y-2">
<label className="text-sm">{t("environments.analysis.charts.date_range")}</label>
<div className="space-y-2">
<Select
value={dateRangeType}
onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preset">{t("environments.analysis.charts.preset")}</SelectItem>
<SelectItem value="custom">{t("environments.analysis.charts.custom_range")}</SelectItem>
</SelectContent>
</Select>
{dateRangeType === "preset" ? (
<Select value={presetValue} onValueChange={handlePresetChange}>
<SelectTrigger className="w-full bg-white">
<SelectValue placeholder={t("environments.analysis.charts.select_preset")} />
</SelectTrigger>
<SelectContent>
{DATE_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{getTranslatedDatePresetLabel(preset.value, t)}
</SelectItem>
))}
{presetValue && !DATE_PRESETS.some((p) => p.value === presetValue) && (
<SelectItem key={presetValue} value={presetValue}>
{presetValue}
</SelectItem>
)}
</SelectContent>
</Select>
) : (
<div className="grid grid-cols-2 gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customStartDate
? format(customStartDate, "MMM dd, yyyy")
: t("environments.analysis.charts.start_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomStartDate(date);
const end = customEndDate ?? date;
if (timeDimension) {
onTimeDimensionChange({
...timeDimension,
dateRange: [date, end],
});
}
if (!customEndDate) setCustomEndDate(end);
}}
value={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start bg-white text-left font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{customEndDate
? format(customEndDate, "MMM dd, yyyy")
: t("environments.analysis.charts.end_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
onChange={(date: Date) => {
setCustomEndDate(date);
const start = customStartDate ?? date;
if (timeDimension) {
onTimeDimensionChange({
...timeDimension,
dateRange: [start, date],
});
}
if (!customStartDate) setCustomStartDate(start);
}}
value={customEndDate || undefined}
minDate={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,345 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
createChartAction,
deleteChartAction,
executeQueryAction,
getChartAction,
updateChartAction,
} from "@/modules/ee/analysis/charts/actions";
import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils";
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
import type {
AnalyticsResponse,
TChart,
TChartType,
TChartWithCreator,
} from "@/modules/ee/analysis/types/analysis";
export interface UseChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
initialChart?: TChartWithCreator;
onSuccess?: () => void;
}
export function useChartDialog({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
}: Readonly<UseChartDialogProps>) {
const { t } = useTranslation();
const router = useRouter();
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | undefined>();
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
useEffect(() => {
let cancelled = false;
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (cancelled) return;
if (result?.data) {
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
} else if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
}
});
}
return () => {
cancelled = true;
};
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
let cancelled = false;
if (!open) return;
if (!chartId) {
setChartData(null);
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
return;
}
const fetchChartById = async (id: string): Promise<TChart> => {
const result = await getChartAction({ environmentId, chartId: id });
if (!result?.data) {
throw new Error(
getFormattedErrorMessage(result) || t("environments.analysis.charts.failed_to_load_chart")
);
}
return result.data;
};
const load = async () => {
setIsLoadingChart(true);
setChartLoadError(null);
try {
const chart = initialChart?.id === chartId ? initialChart : await fetchChartById(chartId);
if (cancelled) return;
setChartName(chart.name);
setSelectedChartType(resolveChartType(chart.type));
setCurrentChartId(chart.id);
const queryResult = await executeQueryAction({ environmentId, query: chart.query });
if (cancelled) return;
if (queryResult?.serverError) {
const errorMsg =
getFormattedErrorMessage(queryResult) ||
t("environments.analysis.charts.failed_to_load_chart_data");
toast.error(errorMsg);
setChartLoadError(errorMsg);
return;
}
if (!Array.isArray(queryResult?.data)) {
const errorMsg = t("environments.analysis.charts.no_data_returned_for_chart");
toast.error(errorMsg);
setChartLoadError(errorMsg);
return;
}
setChartData({
query: chart.query,
chartType: resolveChartType(chart.type),
data: queryResult.data,
});
} catch (error: unknown) {
if (cancelled) return;
const message =
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_load_chart");
toast.error(message);
setChartLoadError(message);
} finally {
if (!cancelled) setIsLoadingChart(false);
}
};
load();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, chartId, environmentId, initialChart]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
if (!currentChartId) {
setChartName(
data.chartType ? `${t("environments.analysis.charts.chart")} ${new Date().toLocaleString()}` : ""
);
}
setSelectedChartType(data.chartType);
};
const handleSaveChart = async () => {
if (!chartData || !chartName.trim()) {
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
return;
}
setIsSaving(true);
try {
if (currentChartId) {
const result = await updateChartAction({
environmentId,
chartId: currentChartId,
chartUpdateInput: {
name: chartName.trim(),
type: chartData.chartType,
query: chartData.query,
config: {},
},
});
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
return;
}
toast.success(t("environments.analysis.charts.chart_updated_successfully"));
} else {
const result = await createChartAction({
environmentId,
chartInput: {
name: chartName.trim(),
type: chartData.chartType,
query: chartData.query,
config: {},
},
});
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
return;
}
setCurrentChartId(result.data.id);
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
}
setIsSaveDialogOpen(false);
onOpenChange(false);
router.refresh();
onSuccess?.();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_save_chart");
toast.error(message);
} finally {
setIsSaving(false);
}
};
const cleanupOrphanChart = async (orphanChartId: string) => {
await deleteChartAction({ environmentId, chartId: orphanChartId }).catch(() => {});
setCurrentChartId(undefined);
};
/** Returns the chart ID to use (existing or newly created), or null on failure. */
const ensureChartForDashboard = async (data: AnalyticsResponse): Promise<string | null> => {
if (currentChartId) return currentChartId;
const chartResult = await createChartAction({
environmentId,
chartInput: {
name: chartName.trim(),
type: data.chartType,
query: data.query,
config: {},
},
});
if (!chartResult?.data) {
toast.error(
(chartResult && getFormattedErrorMessage(chartResult)) ||
t("environments.analysis.charts.failed_to_save_chart")
);
return null;
}
setCurrentChartId(chartResult.data.id);
return chartResult.data.id;
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error(t("environments.analysis.charts.please_select_dashboard"));
return;
}
if (!currentChartId && !chartName.trim()) {
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
return;
}
setIsSaving(true);
let newlyCreatedChartId: string | null = null;
try {
const chartIdToUse = await ensureChartForDashboard(chartData);
if (!chartIdToUse) return;
if (!currentChartId) newlyCreatedChartId = chartIdToUse;
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
});
if (!widgetResult?.data) {
toast.error(
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
);
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
return;
}
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
router.refresh();
onSuccess?.();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
toast.error(message);
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setChartData(null);
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setChartLoadError(null);
onOpenChange(false);
}
};
const handleChartTypeChange = (type: TChartType) => {
setSelectedChartType(type);
setChartData((prev) => (prev ? { ...prev, chartType: type } : null));
};
const initialQuery = initialChart && initialChart.id === chartId ? initialChart.query : undefined;
return {
chartData,
chartName,
setChartName,
selectedChartType,
initialQuery,
setSelectedChartType,
currentChartId,
setCurrentChartId,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
isSaving,
isLoadingChart,
chartLoadError,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
handleClose,
handleChartTypeChange,
};
}

View File

@@ -1,61 +0,0 @@
"use client";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { executeQueryAction } from "@/modules/ee/analysis/charts/actions";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
export interface QueryResult {
query: TChartQuery;
data: TChartDataRow[];
}
export function useChartQuery(environmentId: string, initialQuery?: TChartQuery) {
const { t } = useTranslation();
const [chartData, setChartData] = useState<TChartDataRow[] | null>(null);
const [query, setQuery] = useState<TChartQuery | null>(initialQuery ?? null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const runQuery = async (cubeQuery: TChartQuery): Promise<QueryResult | null> => {
setIsLoading(true);
setError(null);
try {
const result = await executeQueryAction({ environmentId, query: cubeQuery });
if (result?.serverError) {
const msg = getFormattedErrorMessage(result);
setError(msg);
toast.error(msg);
return null;
}
const data = Array.isArray(result?.data) ? result.data : [];
if (data.length === 0) {
const msg = t("environments.analysis.charts.no_data_returned");
setError(msg);
toast.error(msg);
return null;
}
setChartData(data);
setQuery(cubeQuery);
toast.success(t("environments.analysis.charts.query_executed_successfully"));
return { query: cubeQuery, data };
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
setError(msg);
toast.error(msg);
return null;
} finally {
setIsLoading(false);
}
};
return { chartData, query, isLoading, error, runQuery };
}

View File

@@ -1,18 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types";
describe("chart-types", () => {
test("CHART_TYPE_ICONS has all chart types", () => {
expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]);
});
test("getChartTypes returns chart types with translated labels", () => {
const t = vi.fn((key: string) => key);
const result = getChartTypes(t);
expect(result).toHaveLength(5);
expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]);
expect(t).toHaveBeenCalledWith("environments.analysis.charts.chart_type_area");
expect(result[0].label).toBe("environments.analysis.charts.chart_type_area");
});
});

View File

@@ -1,35 +0,0 @@
import type { TFunction } from "i18next";
import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react";
import type React from "react";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
export const DEFAULT_CHART_TYPE: TChartType = "area";
export const CHART_TYPE_ICONS: Record<
TChartType,
React.ComponentType<{ className?: string; strokeWidth?: number }>
> = {
area: AreaChartIcon,
bar: BarChart3Icon,
line: LineChartIcon,
pie: PieChartIcon,
big_number: ActivityIcon,
};
export function getChartTypes(t: TFunction): readonly {
id: TChartType;
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
label: string;
}[] {
return [
{ id: "area", icon: CHART_TYPE_ICONS.area, label: t("environments.analysis.charts.chart_type_area") },
{ id: "bar", icon: CHART_TYPE_ICONS.bar, label: t("environments.analysis.charts.chart_type_bar") },
{ id: "line", icon: CHART_TYPE_ICONS.line, label: t("environments.analysis.charts.chart_type_line") },
{ id: "pie", icon: CHART_TYPE_ICONS.pie, label: t("environments.analysis.charts.chart_type_pie") },
{
id: "big_number",
icon: CHART_TYPE_ICONS.big_number,
label: t("environments.analysis.charts.chart_type_big_number"),
},
];
}

View File

@@ -1,145 +0,0 @@
import { describe, expect, test } from "vitest";
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
formatCellValue,
formatXAxisTick,
preparePieData,
resolveChartType,
validateQueryMembers,
} from "./chart-utils";
describe("chart-utils", () => {
describe("resolveChartType", () => {
test("returns valid chart types", () => {
expect(resolveChartType("area")).toBe("area");
expect(resolveChartType("bar")).toBe("bar");
expect(resolveChartType("line")).toBe("line");
expect(resolveChartType("pie")).toBe("pie");
expect(resolveChartType("big_number")).toBe("big_number");
});
test("defaults to bar for invalid type", () => {
expect(resolveChartType("invalid")).toBe("bar");
expect(resolveChartType("")).toBe("bar");
});
});
describe("preparePieData", () => {
test("returns null for empty or no valid numeric data", () => {
expect(preparePieData([], "count")).toBeNull();
expect(preparePieData([{ label: "A", count: "text" }], "count")).toBeNull();
expect(preparePieData([{ label: "A", count: null }], "count")).toBeNull();
});
test("filters to numeric rows and returns processedData with colors", () => {
const data = [
{ sentiment: "positive", count: 10 },
{ sentiment: "negative", count: 5 },
{ sentiment: "skip", count: "n/a" },
];
const result = preparePieData(data, "count");
expect(result).not.toBeNull();
expect(result!.processedData).toHaveLength(2);
expect(result!.processedData[0].count).toBe(10);
expect(result!.colors[0]).toBe(CHART_BRAND_DARK);
expect(result!.colors[1]).toBe("#00E6CA");
});
});
describe("formatXAxisTick", () => {
test("returns empty for null/undefined", () => {
expect(formatXAxisTick(null)).toBe("");
expect(formatXAxisTick(undefined)).toBe("");
});
test("formats ISO date string", () => {
expect(formatXAxisTick("2024-06-15")).toMatch(/Jun \d+, 2024/);
});
test("passes through non-date string", () => {
expect(formatXAxisTick("hello")).toBe("hello");
});
test("formats number as string when it parses as date, else passes through", () => {
expect(formatXAxisTick(1.5)).toBe("1.5");
});
test("returns empty for boolean", () => {
expect(formatXAxisTick(true)).toBe("");
});
});
describe("formatCellValue", () => {
test("returns empty for null/undefined", () => {
expect(formatCellValue(null)).toBe("");
expect(formatCellValue(undefined)).toBe("");
});
test("formats number with locale", () => {
expect(formatCellValue(1000)).toBe("1,000");
expect(formatCellValue(3.14)).toBe("3.14");
});
test("formats ISO date string", () => {
expect(formatCellValue("2024-01-15")).toMatch(/Jan \d+, 2024/);
});
test("returns string as-is when not date", () => {
expect(formatCellValue("hello")).toBe("hello");
});
test("stringifies object", () => {
expect(formatCellValue({ a: 1 })).toBe('{"a":1}');
});
test("converts boolean and bigint", () => {
expect(formatCellValue(true)).toBe("true");
expect(formatCellValue(123n)).toBe("123");
});
});
describe("validateQueryMembers", () => {
test("does not throw for valid query", () => {
expect(() =>
validateQueryMembers({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
})
).not.toThrow();
});
test("throws for invalid measure", () => {
expect(() => validateQueryMembers({ measures: ["Other.count"] })).toThrow(
/Invalid query members.*Other\.count/
);
});
test("allows TopicsUnnested dimensions (joined cube)", () => {
expect(() => validateQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
});
test("throws for invalid dimension", () => {
expect(() => validateQueryMembers({ dimensions: ["OtherCube.field"] })).toThrow(
/Invalid query members.*OtherCube\.field/
);
});
test("throws for invalid filter member", () => {
expect(() =>
validateQueryMembers({
filters: [{ member: "Invalid.field", operator: "equals", values: ["x"] }],
})
).toThrow(/Invalid query members/);
});
});
describe("constants", () => {
test("CHART_MEASURE_COLORS has expected length", () => {
expect(CHART_MEASURE_COLORS).toHaveLength(6);
expect(CHART_MEASURE_COLORS[0]).toBe(CHART_BRAND_DARK);
});
});
});

View File

@@ -1,119 +0,0 @@
import { format, isValid, parseISO } from "date-fns";
import type { TChartQuery } from "@formbricks/types/analysis";
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
import { ZChartType } from "@/modules/ee/analysis/types/analysis";
export const CHART_BRAND_DARK = "#00C4B8";
export const CHART_BRAND_LIGHT = "#00E6CA";
/** Palette for multi-measure charts (grouped/stacked bars, multi-series line/area). */
export const CHART_MEASURE_COLORS = [
CHART_BRAND_DARK,
"#6366f1", // indigo
"#f59e0b", // amber
"#ef4444", // red
"#8b5cf6", // violet
"#14b8a6", // teal
];
/** Validate a chart type string, defaulting to "bar" if unrecognised. */
export const resolveChartType = (raw: string): TChartType => {
const parsed = ZChartType.safeParse(raw);
return parsed.success ? parsed.data : "bar";
};
const isNumericValue = (val: TChartDataRow[string]): boolean => {
if (val === null || val === undefined || val === "") return false;
const num = Number(val);
return !Number.isNaN(num) && Number.isFinite(num);
};
export const preparePieData = (
data: TChartDataRow[],
dataKey: string
): { processedData: TChartDataRow[]; colors: string[] } | null => {
const validData = data.filter((row) => isNumericValue(row[dataKey]));
const processedData = validData.map((row) => ({ ...row, [dataKey]: Number(row[dataKey]) }));
if (processedData.length === 0) return null;
const colors = processedData.map((_, i) => {
const sat = 70 + (i % 3) * 10;
const light = 45 + (i % 2) * 15;
return `hsl(180, ${sat}%, ${light}%)`;
});
if (colors.length > 0) colors[0] = CHART_BRAND_DARK;
if (colors.length > 1) colors[1] = CHART_BRAND_LIGHT;
return { processedData, colors };
};
/** Format a value for x-axis ticks; ISO date strings become "MMM d, yyyy", others pass through. */
export function formatXAxisTick(value: TChartDataRow[string]): string {
if (value == null) return "";
let str: string;
if (typeof value === "string") str = value;
else if (typeof value === "number") str = String(value);
else return "";
const date = parseISO(str);
if (isValid(date)) return format(date, "MMM d, yyyy");
return str;
}
/**
* Format a cell value for display in tables and tooltips.
* ISO date strings become "MMM d, yyyy"; numbers stay as-is (formatted); objects are stringified.
*/
export function formatCellValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "number") return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
if (typeof value === "string") {
const date = parseISO(value);
if (isValid(date)) return format(date, "MMM d, yyyy");
return value;
}
if (typeof value === "object") return JSON.stringify(value);
if (typeof value === "boolean" || typeof value === "bigint") return String(value);
return "";
}
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
function validateMember(member: string): boolean {
return ALLOWED_CUBE_PREFIXES.some((prefix) => member.startsWith(prefix));
}
/**
* Validates that all measures, dimensions, segments, timeDimensions, and filters
* use only members from FeedbackRecords or joined cubes (e.g. TopicsUnnested).
* @throws Error if any member is invalid
*/
export function validateQueryMembers(query: TChartQuery): void {
const invalid: string[] = [];
for (const m of query.measures ?? []) {
if (!validateMember(m)) invalid.push(m);
}
for (const d of query.dimensions ?? []) {
if (!validateMember(d)) invalid.push(d);
}
for (const s of query.segments ?? []) {
if (!validateMember(s)) invalid.push(s);
}
for (const td of query.timeDimensions ?? []) {
if (!validateMember(td.dimension)) invalid.push(td.dimension);
}
const checkFilters = (f: TChartQuery["filters"]): void => {
if (!f) return;
for (const item of f) {
if ("member" in item && typeof item.member === "string" && !validateMember(item.member)) {
invalid.push(item.member);
}
if ("and" in item && Array.isArray(item.and)) checkFilters(item.and);
if ("or" in item && Array.isArray(item.or)) checkFilters(item.or);
}
};
checkFilters(query.filters);
if (invalid.length > 0) {
throw new Error(
`Invalid query members (must start with FeedbackRecords. or TopicsUnnested.): ${invalid.join(", ")}`
);
}
}

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