mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-18 10:09:49 -06:00
Compare commits
8 Commits
poc-surfac
...
typeerror-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13c7a4a0e9 | ||
|
|
817e108ff5 | ||
|
|
33542d0c54 | ||
|
|
f37d22f13d | ||
|
|
202ae903ac | ||
|
|
6ab5cc367c | ||
|
|
21559045ba | ||
|
|
d7c57a7a48 |
@@ -1,196 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetDisplaysWithContactAction = z.object({
|
||||
surveyId: ZId,
|
||||
limit: z.number().int().min(1),
|
||||
offset: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const mockSurveyId = "clqkr5smu000108jy50v6g5k4";
|
||||
|
||||
describe("ZGetDisplaysWithContactAction Schema Validation", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("accepts valid positive limit and non-negative offset", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(10);
|
||||
expect(result.data.offset).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts limit of 1 and offset of 0", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(1);
|
||||
expect(result.data.offset).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts large positive values", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 100,
|
||||
offset: 50,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(100);
|
||||
expect(result.data.offset).toBe(50);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation - Negative Values", () => {
|
||||
test("rejects negative limit", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: -1,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain("limit");
|
||||
expect(result.error.issues[0].message).toMatch(/greater than or equal to 1/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative offset", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 10,
|
||||
offset: -5,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain("offset");
|
||||
expect(result.error.issues[0].message).toMatch(/greater than or equal to 0/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects both negative limit and offset", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: -10,
|
||||
offset: -5,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.length).toBeGreaterThanOrEqual(2);
|
||||
const paths = result.error.issues.map((i) => i.path[0]);
|
||||
expect(paths).toContain("limit");
|
||||
expect(paths).toContain("offset");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects zero limit (must be at least 1)", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain("limit");
|
||||
expect(result.error.issues[0].message).toMatch(/greater than or equal to 1/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation - Non-Integer Values", () => {
|
||||
test("rejects decimal limit", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 10.5,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain("limit");
|
||||
expect(result.error.issues[0].message).toMatch(/integer/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects decimal offset", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 10,
|
||||
offset: 5.7,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain("offset");
|
||||
expect(result.error.issues[0].message).toMatch(/integer/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects both decimal values", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 10.5,
|
||||
offset: 5.7,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.length).toBeGreaterThanOrEqual(2);
|
||||
const paths = result.error.issues.map((i) => i.path[0]);
|
||||
expect(paths).toContain("limit");
|
||||
expect(paths).toContain("offset");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation - Invalid surveyId", () => {
|
||||
test("rejects invalid surveyId format", () => {
|
||||
const result = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: "invalid-id",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toContain("surveyId");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("ensures Prisma skip/take receive valid integers", () => {
|
||||
const validResult = ZGetDisplaysWithContactAction.safeParse({
|
||||
surveyId: mockSurveyId,
|
||||
limit: 15,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(validResult.success).toBe(true);
|
||||
if (validResult.success) {
|
||||
expect(Number.isInteger(validResult.data.limit)).toBe(true);
|
||||
expect(Number.isInteger(validResult.data.offset)).toBe(true);
|
||||
expect(validResult.data.limit).toBeGreaterThan(0);
|
||||
expect(validResult.data.offset).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getDisplaysWithContactAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
const DISPLAYS_PER_PAGE = 15;
|
||||
|
||||
interface SummaryImpressionsProps {
|
||||
surveyId: string;
|
||||
environmentId: string;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const SummaryImpressions = ({ surveyId, environmentId, locale }: SummaryImpressionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [displaysError, setDisplaysError] = useState<string | null>(null);
|
||||
|
||||
const fetchDisplays = useCallback(
|
||||
async (offset: number) => {
|
||||
try {
|
||||
const response = await getDisplaysWithContactAction({
|
||||
surveyId,
|
||||
limit: DISPLAYS_PER_PAGE,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (response?.serverError) {
|
||||
const errorMessage = response.serverError || t("common.something_went_wrong");
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (response?.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
setDisplaysError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[surveyId, t]
|
||||
);
|
||||
|
||||
const loadInitialDisplays = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setDisplaysError(null);
|
||||
try {
|
||||
const data = await fetchDisplays(0);
|
||||
setDisplays(data);
|
||||
setHasMore(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch {
|
||||
// Error is already handled in fetchDisplays
|
||||
setDisplays([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchDisplays]);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialDisplays();
|
||||
}, [loadInitialDisplays]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
try {
|
||||
const data = await fetchDisplays(displays.length);
|
||||
setDisplays((prev) => [...prev, ...data]);
|
||||
setHasMore(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch {
|
||||
// Error is already handled in fetchDisplays
|
||||
setHasMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
|
||||
if (!display.contact) return "";
|
||||
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
|
||||
};
|
||||
|
||||
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={loadInitialDisplays} 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={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<InfoIcon className="h-4 w-4 shrink-0" />
|
||||
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
quotasCount: number;
|
||||
isLoading: boolean;
|
||||
tab: "dropOffs" | "quotas" | "impressions" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
|
||||
tab: "dropOffs" | "quotas" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
|
||||
const { t } = useTranslation();
|
||||
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
|
||||
const handleTabChange = (val: "dropOffs" | "quotas") => {
|
||||
const change = tab === val ? undefined : val;
|
||||
setTab(change);
|
||||
};
|
||||
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
|
||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||
)}>
|
||||
<InteractiveCard
|
||||
key="impressions"
|
||||
tab="impressions"
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.impressions")}
|
||||
percentage={null}
|
||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleTabChange("impressions")}
|
||||
isActive={tab === "impressions"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.starts")}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]
|
||||
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 { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
@@ -58,7 +57,7 @@ export const SummaryPage = ({
|
||||
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();
|
||||
@@ -122,9 +121,6 @@ export const SummaryPage = ({
|
||||
setTab={setTab}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
{tab === "impressions" && (
|
||||
<SummaryImpressions surveyId={surveyId} environmentId={environment.id} locale={locale} />
|
||||
)}
|
||||
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
||||
|
||||
interface InteractiveCardProps {
|
||||
tab: "dropOffs" | "quotas" | "impressions";
|
||||
tab: "dropOffs" | "quotas";
|
||||
label: string;
|
||||
percentage: number | null;
|
||||
percentage: number;
|
||||
value: React.ReactNode;
|
||||
tooltipText: string;
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -98,10 +98,11 @@ describe("updateResponseWithQuotaEvaluation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should return response without quotaFull when quota evaluation returns no quotaFull", async () => {
|
||||
test("should return response with quotaFull as undefined when quota evaluation returns no quotaFull", async () => {
|
||||
mockUpdateResponse.mockResolvedValue(mockResponse);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: undefined,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
@@ -117,8 +118,11 @@ describe("updateResponseWithQuotaEvaluation", () => {
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result).not.toHaveProperty("quotaFull");
|
||||
expect(result).toEqual({
|
||||
...mockResponse,
|
||||
quotaFull: undefined,
|
||||
});
|
||||
expect(result).toHaveProperty("quotaFull");
|
||||
});
|
||||
|
||||
test("should use default language when response language is null", async () => {
|
||||
@@ -126,6 +130,7 @@ describe("updateResponseWithQuotaEvaluation", () => {
|
||||
mockUpdateResponse.mockResolvedValue(responseWithNullLanguage);
|
||||
mockEvaluateResponseQuotas.mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: undefined,
|
||||
});
|
||||
|
||||
const result = await updateResponseWithQuotaEvaluation(mockResponseId, mockResponseInput);
|
||||
@@ -140,6 +145,9 @@ describe("updateResponseWithQuotaEvaluation", () => {
|
||||
tx: mockTx,
|
||||
});
|
||||
|
||||
expect(result).toEqual(responseWithNullLanguage);
|
||||
expect(result).toEqual({
|
||||
...responseWithNullLanguage,
|
||||
quotaFull: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export const updateResponseWithQuotaEvaluation = async (
|
||||
|
||||
return {
|
||||
...response,
|
||||
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
|
||||
quotaFull: quotaResult.quotaFull,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("createResponseWithQuotaEvaluation", () => {
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
|
||||
test("should return response without quotaFull when no quota violations", async () => {
|
||||
test("should return response with quotaFull as undefined when no quota violations", async () => {
|
||||
// Mock quota evaluation to return no violations
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
@@ -198,8 +198,9 @@ describe("createResponseWithQuotaEvaluation", () => {
|
||||
displayId: null,
|
||||
contact: null,
|
||||
tags: [],
|
||||
quotaFull: undefined,
|
||||
});
|
||||
expect(result).not.toHaveProperty("quotaFull");
|
||||
expect(result).toHaveProperty("quotaFull");
|
||||
});
|
||||
|
||||
test("should return response with quotaFull when quota is exceeded with endSurvey action", async () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export const createResponseWithQuotaEvaluation = async (
|
||||
|
||||
return {
|
||||
...response,
|
||||
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
|
||||
quotaFull: quotaResult.quotaFull,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -225,10 +225,13 @@ describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should create response and return it without quotaFull when no quota is full", async () => {
|
||||
test("should create response and return it with quotaFull as null when no quota is full", async () => {
|
||||
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(result).toEqual({
|
||||
...expectedResponse,
|
||||
quotaFull: null,
|
||||
});
|
||||
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
|
||||
surveyId: mockResponseInput.surveyId,
|
||||
responseId: expectedResponse.id,
|
||||
@@ -262,4 +265,19 @@ describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
tx: mockTx,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle quota evaluation returning undefined quotaFull without throwing", async () => {
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: undefined,
|
||||
});
|
||||
|
||||
const result: TResponseWithQuotaFull = await createResponseWithQuotaEvaluation(mockResponseInput);
|
||||
|
||||
expect(result).toEqual({
|
||||
...expectedResponse,
|
||||
quotaFull: undefined,
|
||||
});
|
||||
expect(result).toHaveProperty("quotaFull");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const createResponseWithQuotaEvaluation = async (
|
||||
|
||||
return {
|
||||
...response,
|
||||
...(quotaResult.quotaFull && { quotaFull: quotaResult.quotaFull }),
|
||||
quotaFull: quotaResult.quotaFull,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -401,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
|
||||
@@ -621,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
|
||||
@@ -633,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
|
||||
@@ -648,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
|
||||
@@ -1851,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
|
||||
@@ -1892,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
|
||||
@@ -2043,12 +2036,12 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
|
||||
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
|
||||
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
|
||||
environments/workspace/look/advanced_styling_field_height: f4da6d7ecd26e3fa75cfea03abb60c00
|
||||
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
||||
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
||||
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: b704fc67e805223992c811d6f86a9c00
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
|
||||
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
|
||||
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
||||
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
||||
|
||||
@@ -2,8 +2,8 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -42,93 +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, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const displays = await prisma.display.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return displays.map((display) => ({
|
||||
id: display.id,
|
||||
createdAt: display.createdAt,
|
||||
surveyId: display.surveyId,
|
||||
contact: display.contact
|
||||
? {
|
||||
id: display.contact.id,
|
||||
attributes: display.contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { mockDisplayId, mockSurveyId } from "./__mocks__/data.mock";
|
||||
import { prisma } from "@/lib/__mocks__/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisplaysWithContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "userId" }, value: "user-123" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: [{ attributeKey: { key: "userId" }, value: "user-456" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("getDisplaysByContactId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays for a contact ordered by createdAt desc", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysForContact as any);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual(mockDisplaysForContact);
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: { contactId: mockContactId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when contact has no displays", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the contactId is invalid", async () => {
|
||||
await expect(getDisplaysByContactId("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplaysBySurveyIdWithContact", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays with contact attributes transformed", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysWithContact as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: { email: "test@example.com", userId: "user-123" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: { userId: "user-456" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("calls prisma with correct where clause and pagination", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 15,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no displays found", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles display with null contact", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the surveyId is invalid", async () => {
|
||||
await expect(getDisplaysBySurveyIdWithContact("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -118,10 +118,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
// Inputs
|
||||
inputTextColor: { light: _colors["inputTextColor.light"] },
|
||||
inputBorderRadius: 8,
|
||||
inputHeight: 40,
|
||||
inputHeight: 20,
|
||||
inputFontSize: 14,
|
||||
inputPaddingX: 16,
|
||||
inputPaddingY: 16,
|
||||
inputPaddingX: 8,
|
||||
inputPaddingY: 8,
|
||||
inputPlaceholderOpacity: 0.5,
|
||||
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
@@ -149,6 +149,42 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
|
||||
*
|
||||
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
|
||||
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
|
||||
*
|
||||
* When loading v4.6 data the new fields are absent. Without this helper the
|
||||
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
|
||||
* colour), causing a visible mismatch. This function derives the new fields
|
||||
* from the actually-saved legacy fields so the preview and form stay coherent.
|
||||
*
|
||||
* Only sets a field when the legacy source exists AND the new field is absent.
|
||||
*/
|
||||
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
|
||||
const light = (key: string): string | undefined =>
|
||||
(saved[key] as { light?: string } | null | undefined)?.light;
|
||||
|
||||
const q = light("questionColor");
|
||||
const b = light("brandColor");
|
||||
const i = light("inputColor");
|
||||
|
||||
return {
|
||||
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
|
||||
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
|
||||
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
|
||||
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
|
||||
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
|
||||
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
|
||||
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
|
||||
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
|
||||
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
|
||||
...(b &&
|
||||
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a complete TProjectStyling object from a single brand color.
|
||||
*
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
|
||||
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
|
||||
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
|
||||
"advanced_styling_field_height": "Höhe",
|
||||
"advanced_styling_field_height": "Mindesthöhe",
|
||||
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
|
||||
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
|
||||
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
|
||||
"advanced_styling_field_input_height_description": "Steuert die Höhe des Eingabefelds.",
|
||||
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
|
||||
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
|
||||
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"top_right": "Top Right",
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Unknown survey",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
@@ -657,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 contact’s data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact’s data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts’ data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
"displays": "Displays",
|
||||
"edit_attribute": "Edit attribute",
|
||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||
"edit_attribute_values": "Edit attributes",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Scales the headline text.",
|
||||
"advanced_styling_field_headline_weight": "Headline Font Weight",
|
||||
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
|
||||
"advanced_styling_field_height": "Height",
|
||||
"advanced_styling_field_height": "Minimum Height",
|
||||
"advanced_styling_field_indicator_bg": "Indicator Background",
|
||||
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
|
||||
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
|
||||
"advanced_styling_field_input_height_description": "Controls the input field height.",
|
||||
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
|
||||
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
|
||||
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
|
||||
"advanced_styling_field_height": "Altura",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"advanced_styling_field_indicator_bg": "Fondo del indicador",
|
||||
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
|
||||
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
|
||||
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
|
||||
"advanced_styling_field_input_height_description": "Controla la altura del campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
|
||||
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
|
||||
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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.",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
|
||||
"advanced_styling_field_headline_weight": "Graisse de police du titre",
|
||||
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
|
||||
"advanced_styling_field_height": "Hauteur",
|
||||
"advanced_styling_field_height": "Hauteur minimale",
|
||||
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
|
||||
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
|
||||
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
|
||||
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
|
||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur du champ de saisie.",
|
||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
|
||||
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
|
||||
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
|
||||
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
|
||||
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
|
||||
"advanced_styling_field_height": "Magasság",
|
||||
"advanced_styling_field_height": "Minimális magasság",
|
||||
"advanced_styling_field_indicator_bg": "Jelző háttere",
|
||||
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
|
||||
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
|
||||
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
|
||||
"advanced_styling_field_input_height_description": "A beviteli mező magasságát vezérli.",
|
||||
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
|
||||
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
|
||||
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"top_right": "右上",
|
||||
"try_again": "もう一度お試しください",
|
||||
"type": "種類",
|
||||
"unknown_survey": "不明なフォーム",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
|
||||
"update": "更新",
|
||||
"updated": "更新済み",
|
||||
@@ -657,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": "属性を編集",
|
||||
@@ -669,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": "回答が見つかりません",
|
||||
@@ -684,8 +681,6 @@
|
||||
"select_a_survey": "フォームを選択",
|
||||
"select_attribute": "属性を選択",
|
||||
"select_attribute_key": "属性キーを選択",
|
||||
"survey_viewed": "フォームを閲覧",
|
||||
"survey_viewed_at": "閲覧日時",
|
||||
"system_attributes": "システム属性",
|
||||
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
|
||||
"unlock_contacts_title": "上位プランで連絡先をアンロック",
|
||||
@@ -1952,7 +1947,6 @@
|
||||
"filtered_responses_excel": "フィルター済み回答 (Excel)",
|
||||
"generating_qr_code": "QRコードを生成中",
|
||||
"impressions": "表示回数",
|
||||
"impressions_identified_only": "識別済みコンタクトからのインプレッションのみを表示しています",
|
||||
"impressions_tooltip": "フォームが表示された回数。",
|
||||
"in_app": {
|
||||
"connection_description": "このフォームは、以下の条件に一致するあなたのウェブサイトのユーザーに表示されます",
|
||||
@@ -1995,7 +1989,6 @@
|
||||
"last_quarter": "前四半期",
|
||||
"last_year": "昨年",
|
||||
"limit": "制限",
|
||||
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"other_values_found": "他の値が見つかりました",
|
||||
"overall": "全体",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
|
||||
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
|
||||
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
|
||||
"advanced_styling_field_height": "高さ",
|
||||
"advanced_styling_field_height": "最小の高さ",
|
||||
"advanced_styling_field_indicator_bg": "インジケーターの背景",
|
||||
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
|
||||
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
|
||||
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
|
||||
"advanced_styling_field_input_height_description": "入力フィールドの高さを調整します。",
|
||||
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
|
||||
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
|
||||
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
|
||||
"advanced_styling_field_headline_weight": "Letterdikte kop",
|
||||
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
|
||||
"advanced_styling_field_height": "Hoogte",
|
||||
"advanced_styling_field_height": "Minimale hoogte",
|
||||
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
|
||||
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
|
||||
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
|
||||
"advanced_styling_field_input_height_description": "Bepaalt de hoogte van het invoerveld.",
|
||||
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
|
||||
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
|
||||
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
||||
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
||||
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
||||
"advanced_styling_field_height": "Altura",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
||||
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
||||
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
||||
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
||||
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
||||
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
||||
"advanced_styling_field_height": "Altura",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
||||
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
||||
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
||||
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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.",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Scalează textul titlului.",
|
||||
"advanced_styling_field_headline_weight": "Grosime font titlu",
|
||||
"advanced_styling_field_headline_weight_description": "Face textul titlului mai subțire sau mai îngroșat.",
|
||||
"advanced_styling_field_height": "Înălțime",
|
||||
"advanced_styling_field_height": "Înălțime minimă",
|
||||
"advanced_styling_field_indicator_bg": "Fundal indicator",
|
||||
"advanced_styling_field_indicator_bg_description": "Colorează partea umplută a barei.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rotunjește colțurile câmpurilor de introducere.",
|
||||
"advanced_styling_field_input_font_size_description": "Scalează textul introdus în câmpuri.",
|
||||
"advanced_styling_field_input_height_description": "Controlează înălțimea câmpului de introducere.",
|
||||
"advanced_styling_field_input_height_description": "Controlează înălțimea minimă a câmpului de introducere.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adaugă spațiu la stânga și la dreapta.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adaugă spațiu deasupra și dedesubt.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Estompează textul de sugestie din placeholder.",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"top_right": "Вверху справа",
|
||||
"try_again": "Попробуйте ещё раз",
|
||||
"type": "Тип",
|
||||
"unknown_survey": "Неизвестный опрос",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
|
||||
"update": "Обновить",
|
||||
"updated": "Обновлено",
|
||||
@@ -657,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": "Редактировать атрибуты",
|
||||
@@ -669,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": "Ответы не найдены",
|
||||
@@ -684,8 +681,6 @@
|
||||
"select_a_survey": "Выберите опрос",
|
||||
"select_attribute": "Выберите атрибут",
|
||||
"select_attribute_key": "Выберите ключ атрибута",
|
||||
"survey_viewed": "Опрос просмотрен",
|
||||
"survey_viewed_at": "Просмотрено",
|
||||
"system_attributes": "Системные атрибуты",
|
||||
"unlock_contacts_description": "Управляйте контактами и отправляйте целевые опросы",
|
||||
"unlock_contacts_title": "Откройте доступ к контактам с более высоким тарифом",
|
||||
@@ -1952,7 +1947,6 @@
|
||||
"filtered_responses_excel": "Отфильтрованные ответы (Excel)",
|
||||
"generating_qr_code": "Генерация QR-кода",
|
||||
"impressions": "Просмотры",
|
||||
"impressions_identified_only": "Показаны только показы от идентифицированных контактов",
|
||||
"impressions_tooltip": "Количество раз, когда опрос был просмотрен.",
|
||||
"in_app": {
|
||||
"connection_description": "Опрос будет показан пользователям вашего сайта, которые соответствуют указанным ниже критериям",
|
||||
@@ -1995,7 +1989,6 @@
|
||||
"last_quarter": "Прошлый квартал",
|
||||
"last_year": "Прошлый год",
|
||||
"limit": "Лимит",
|
||||
"no_identified_impressions": "Нет показов от идентифицированных контактов",
|
||||
"no_responses_found": "Ответы не найдены",
|
||||
"other_values_found": "Найдены другие значения",
|
||||
"overall": "В целом",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Масштабирует текст заголовка.",
|
||||
"advanced_styling_field_headline_weight": "Толщина шрифта заголовка",
|
||||
"advanced_styling_field_headline_weight_description": "Делает текст заголовка тоньше или жирнее.",
|
||||
"advanced_styling_field_height": "Высота",
|
||||
"advanced_styling_field_height": "Минимальная высота",
|
||||
"advanced_styling_field_indicator_bg": "Фон индикатора",
|
||||
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
|
||||
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
|
||||
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
|
||||
"advanced_styling_field_input_height_description": "Определяет высоту поля ввода.",
|
||||
"advanced_styling_field_input_height_description": "Определяет минимальную высоту поля ввода.",
|
||||
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
|
||||
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
|
||||
|
||||
@@ -428,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",
|
||||
@@ -657,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",
|
||||
@@ -669,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",
|
||||
@@ -684,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",
|
||||
@@ -1952,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",
|
||||
@@ -1995,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",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ändrar storleken på rubriken.",
|
||||
"advanced_styling_field_headline_weight": "Rubrikens teckentjocklek",
|
||||
"advanced_styling_field_headline_weight_description": "Gör rubriktexten tunnare eller fetare.",
|
||||
"advanced_styling_field_height": "Höjd",
|
||||
"advanced_styling_field_height": "Minsta höjd",
|
||||
"advanced_styling_field_indicator_bg": "Indikatorns bakgrund",
|
||||
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
|
||||
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
|
||||
"advanced_styling_field_input_height_description": "Styr höjden på inmatningsfältet.",
|
||||
"advanced_styling_field_input_height_description": "Styr den minsta höjden på inmatningsfältet.",
|
||||
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
|
||||
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"top_right": "右上",
|
||||
"try_again": "再试一次",
|
||||
"type": "类型",
|
||||
"unknown_survey": "未知调查",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
@@ -657,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": "编辑属性",
|
||||
@@ -669,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": "未找到 响应",
|
||||
@@ -684,8 +681,6 @@
|
||||
"select_a_survey": "选择一个调查",
|
||||
"select_attribute": "选择 属性",
|
||||
"select_attribute_key": "选择属性键",
|
||||
"survey_viewed": "已查看调查",
|
||||
"survey_viewed_at": "查看时间",
|
||||
"system_attributes": "系统属性",
|
||||
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
|
||||
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
|
||||
@@ -1952,7 +1947,6 @@
|
||||
"filtered_responses_excel": "过滤 反馈 (Excel)",
|
||||
"generating_qr_code": "正在生成二维码",
|
||||
"impressions": "印象",
|
||||
"impressions_identified_only": "仅显示已识别联系人的展示次数",
|
||||
"impressions_tooltip": "调查 被 查看 的 次数",
|
||||
"in_app": {
|
||||
"connection_description": "调查将显示给符合以下条件的您网站用户",
|
||||
@@ -1995,7 +1989,6 @@
|
||||
"last_quarter": "上季度",
|
||||
"last_year": "去年",
|
||||
"limit": "限额",
|
||||
"no_identified_impressions": "没有已识别联系人的展示次数",
|
||||
"no_responses_found": "未找到响应",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整体",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "调整主标题文字大小。",
|
||||
"advanced_styling_field_headline_weight": "标题字体粗细",
|
||||
"advanced_styling_field_headline_weight_description": "设置主标题文字的粗细。",
|
||||
"advanced_styling_field_height": "高度",
|
||||
"advanced_styling_field_height": "最小高度",
|
||||
"advanced_styling_field_indicator_bg": "指示器背景",
|
||||
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
|
||||
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
|
||||
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
|
||||
"advanced_styling_field_input_height_description": "控制输入框高度。",
|
||||
"advanced_styling_field_input_height_description": "设置输入框的最小高度。",
|
||||
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
|
||||
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"top_right": "右上",
|
||||
"try_again": "再試一次",
|
||||
"type": "類型",
|
||||
"unknown_survey": "未知問卷",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
|
||||
"update": "更新",
|
||||
"updated": "已更新",
|
||||
@@ -657,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": "編輯屬性",
|
||||
@@ -669,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": "找不到回應",
|
||||
@@ -684,8 +681,6 @@
|
||||
"select_a_survey": "選擇問卷",
|
||||
"select_attribute": "選取屬性",
|
||||
"select_attribute_key": "選取屬性鍵值",
|
||||
"survey_viewed": "已查看問卷",
|
||||
"survey_viewed_at": "查看時間",
|
||||
"system_attributes": "系統屬性",
|
||||
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
|
||||
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
|
||||
@@ -1952,7 +1947,6 @@
|
||||
"filtered_responses_excel": "篩選回應 (Excel)",
|
||||
"generating_qr_code": "正在生成 QR code",
|
||||
"impressions": "曝光數",
|
||||
"impressions_identified_only": "僅顯示已識別聯絡人的曝光次數",
|
||||
"impressions_tooltip": "問卷已檢視的次數。",
|
||||
"in_app": {
|
||||
"connection_description": "調查將顯示給符合以下列出條件的網站用戶",
|
||||
@@ -1995,7 +1989,6 @@
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"limit": "限制",
|
||||
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
|
||||
"no_responses_found": "找不到回應",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
@@ -2160,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "調整標題文字的大小。",
|
||||
"advanced_styling_field_headline_weight": "標題字體粗細",
|
||||
"advanced_styling_field_headline_weight_description": "讓標題文字變細或變粗。",
|
||||
"advanced_styling_field_height": "高度",
|
||||
"advanced_styling_field_height": "最小高度",
|
||||
"advanced_styling_field_indicator_bg": "指示器背景",
|
||||
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
|
||||
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
|
||||
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
|
||||
"advanced_styling_field_input_height_description": "調整輸入欄位的高度。",
|
||||
"advanced_styling_field_input_height_description": "設定輸入欄位的最小高度。",
|
||||
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
|
||||
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
|
||||
|
||||
@@ -30,4 +30,4 @@ export const rateLimitConfigs = {
|
||||
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
|
||||
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, EyeIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplay } from "@formbricks/types/displays";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { useMembershipRole } from "@/lib/membership/hooks/useMembershipRole";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
type TTimelineItem =
|
||||
| { type: "display"; data: Pick<TDisplay, "id" | "createdAt" | "surveyId"> }
|
||||
| { type: "response"; data: TResponseWithQuotas };
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
surveys: TSurvey[];
|
||||
user: TUser;
|
||||
responses: TResponseWithQuotas[];
|
||||
displays: Pick<TDisplay, "id" | "createdAt" | "surveyId">[];
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
locale: TUserLocale;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const ActivityTimeline = ({
|
||||
surveys,
|
||||
user,
|
||||
responses: initialResponses,
|
||||
displays,
|
||||
environment,
|
||||
environmentTags,
|
||||
locale,
|
||||
projectPermission,
|
||||
}: ActivityTimelineProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [responses, setResponses] = useState(initialResponses);
|
||||
const [isReversed, setIsReversed] = useState(false);
|
||||
|
||||
// Fetch membership role once for the environment
|
||||
const { membershipRole } = useMembershipRole(environment.id, user.id);
|
||||
|
||||
// Compute isReadOnly once for all responses
|
||||
const isReadOnly = useMemo(() => {
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||
return isMember && hasReadAccess;
|
||||
}, [membershipRole, projectPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
setResponses(initialResponses);
|
||||
}, [initialResponses]);
|
||||
|
||||
const updateResponseList = (responseIds: string[]) => {
|
||||
setResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
|
||||
};
|
||||
|
||||
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
|
||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||
};
|
||||
|
||||
const timelineItems = useMemo(() => {
|
||||
const displayItems: TTimelineItem[] = displays.map((d) => ({
|
||||
type: "display" as const,
|
||||
data: d,
|
||||
}));
|
||||
|
||||
const responseItems: TTimelineItem[] = responses.map((r) => ({
|
||||
type: "response" as const,
|
||||
data: r,
|
||||
}));
|
||||
|
||||
const merged = [...displayItems, ...responseItems].sort((a, b) => {
|
||||
const aTime = new Date(a.data.createdAt).getTime();
|
||||
const bTime = new Date(b.data.createdAt).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
return isReversed ? [...merged].reverse() : merged;
|
||||
}, [displays, responses, isReversed]);
|
||||
|
||||
const toggleSort = () => {
|
||||
setIsReversed((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">{t("common.activity")}</h2>
|
||||
<div className="text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSort}
|
||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||
<ArrowDownUpIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{timelineItems.length === 0 ? (
|
||||
<EmptyState text={t("environments.contacts.no_activity_yet")} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{timelineItems.map((item) =>
|
||||
item.type === "display" ? (
|
||||
<DisplayCard
|
||||
key={`display-${item.data.id}`}
|
||||
display={item.data}
|
||||
surveys={surveys}
|
||||
environmentId={environment.id}
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<ResponseSurveyCard
|
||||
key={`response-${item.data.id}`}
|
||||
response={item.data}
|
||||
surveys={surveys}
|
||||
user={user}
|
||||
environmentTags={environmentTags}
|
||||
environment={environment}
|
||||
updateResponseList={updateResponseList}
|
||||
updateResponse={updateResponse}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayCard = ({
|
||||
display,
|
||||
surveys,
|
||||
environmentId,
|
||||
locale,
|
||||
}: {
|
||||
display: Pick<TDisplay, "id" | "createdAt" | "surveyId">;
|
||||
surveys: TSurvey[];
|
||||
environmentId: string;
|
||||
locale: TUserLocale;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const survey = surveys.find((s) => s.id === display.surveyId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100">
|
||||
<EyeIcon className="h-4 w-4 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">{t("environments.contacts.survey_viewed")}</p>
|
||||
{survey ? (
|
||||
<Link
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}
|
||||
className="text-sm font-medium text-slate-700 hover:underline">
|
||||
{survey.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-slate-500">{t("common.unknown_survey")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">{timeSince(display.createdAt.toString(), locale)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponseSurveyCard = ({
|
||||
response,
|
||||
surveys,
|
||||
user,
|
||||
environmentTags,
|
||||
environment,
|
||||
updateResponseList,
|
||||
updateResponse,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: {
|
||||
response: TResponseWithQuotas;
|
||||
surveys: TSurvey[];
|
||||
user: TUser;
|
||||
environmentTags: TTag[];
|
||||
environment: TEnvironment;
|
||||
updateResponseList: (responseIds: string[]) => void;
|
||||
updateResponse: (responseId: string, response: TResponseWithQuotas) => void;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}) => {
|
||||
const survey = surveys.find((s) => s.id === response.surveyId);
|
||||
|
||||
if (!survey) return null;
|
||||
|
||||
return (
|
||||
<SingleResponseCard
|
||||
response={response}
|
||||
survey={replaceHeadlineRecall(survey, "default")}
|
||||
user={user}
|
||||
environmentTags={environmentTags}
|
||||
environment={environment}
|
||||
updateResponseList={updateResponseList}
|
||||
updateResponse={updateResponse}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
@@ -18,12 +17,8 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
}
|
||||
|
||||
const [responses, displays] = await Promise.all([
|
||||
getResponsesByContactId(contactId),
|
||||
getDisplaysByContactId(contactId),
|
||||
]);
|
||||
const responses = await getResponsesByContactId(contactId);
|
||||
const numberOfResponses = responses?.length || 0;
|
||||
const numberOfDisplays = displays?.length || 0;
|
||||
|
||||
const systemAttributes = attributesWithKeyInfo
|
||||
.filter((attr) => attr.type === "default")
|
||||
@@ -90,11 +85,6 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dt className="text-sm font-medium text-slate-500">{t("common.responses")}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{numberOfResponses}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">{t("environments.contacts.displays")}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{numberOfDisplays}</dd>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { useMembershipRole } from "@/lib/membership/hooks/useMembershipRole";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ResponseTimelineProps {
|
||||
surveys: TSurvey[];
|
||||
user: TUser;
|
||||
responses: TResponseWithQuotas[];
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
locale: TUserLocale;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const ResponseFeed = ({
|
||||
responses,
|
||||
environment,
|
||||
surveys,
|
||||
user,
|
||||
environmentTags,
|
||||
locale,
|
||||
projectPermission,
|
||||
}: ResponseTimelineProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [fetchedResponses, setFetchedResponses] = useState(responses);
|
||||
|
||||
useEffect(() => {
|
||||
setFetchedResponses(responses);
|
||||
}, [responses]);
|
||||
|
||||
const updateResponseList = (responseIds: string[]) => {
|
||||
setFetchedResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
|
||||
};
|
||||
|
||||
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
|
||||
setFetchedResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{fetchedResponses.length === 0 ? (
|
||||
<EmptyState text={t("environments.contacts.no_responses_found")} />
|
||||
) : (
|
||||
fetchedResponses.map((response) => (
|
||||
<ResponseSurveyCard
|
||||
key={response.id}
|
||||
response={response}
|
||||
surveys={surveys}
|
||||
user={user}
|
||||
environmentTags={environmentTags}
|
||||
environment={environment}
|
||||
updateResponseList={updateResponseList}
|
||||
updateResponse={updateResponse}
|
||||
locale={locale}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponseSurveyCard = ({
|
||||
response,
|
||||
surveys,
|
||||
user,
|
||||
environmentTags,
|
||||
environment,
|
||||
updateResponseList,
|
||||
updateResponse,
|
||||
locale,
|
||||
projectPermission,
|
||||
}: {
|
||||
response: TResponseWithQuotas;
|
||||
surveys: TSurvey[];
|
||||
user: TUser;
|
||||
environmentTags: TTag[];
|
||||
environment: TEnvironment;
|
||||
updateResponseList: (responseIds: string[]) => void;
|
||||
updateResponse: (responseId: string, response: TResponseWithQuotas) => void;
|
||||
locale: TUserLocale;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}) => {
|
||||
const survey = surveys.find((survey) => {
|
||||
return survey.id === response.surveyId;
|
||||
});
|
||||
|
||||
const { membershipRole } = useMembershipRole(survey?.environmentId || "", user.id);
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
return (
|
||||
<div key={response.id}>
|
||||
{survey && (
|
||||
<SingleResponseCard
|
||||
response={response}
|
||||
survey={replaceHeadlineRecall(survey, "default")}
|
||||
user={user}
|
||||
environmentTags={environmentTags}
|
||||
environment={environment}
|
||||
updateResponseList={updateResponseList}
|
||||
updateResponse={updateResponse}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { getServerSession } from "next-auth";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getDisplaysByContactId } from "@/lib/display/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
@@ -11,34 +10,27 @@ import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { ActivityTimeline } from "./activity-timeline";
|
||||
import { ResponseTimeline } from "./response-timeline";
|
||||
|
||||
interface ActivitySectionProps {
|
||||
interface ResponseSectionProps {
|
||||
environment: TEnvironment;
|
||||
contactId: string;
|
||||
environmentTags: TTag[];
|
||||
}
|
||||
|
||||
export const ActivitySection = async ({ environment, contactId, environmentTags }: ActivitySectionProps) => {
|
||||
const [responses, displays] = await Promise.all([
|
||||
getResponsesByContactId(contactId),
|
||||
getDisplaysByContactId(contactId),
|
||||
]);
|
||||
|
||||
const allSurveyIds = [
|
||||
...new Set([...(responses?.map((r) => r.surveyId) || []), ...displays.map((d) => d.surveyId)]),
|
||||
];
|
||||
|
||||
const surveys: TSurvey[] = allSurveyIds.length === 0 ? [] : ((await getSurveys(environment.id)) ?? []);
|
||||
|
||||
export const ResponseSection = async ({ environment, contactId, environmentTags }: ResponseSectionProps) => {
|
||||
const responses = await getResponsesByContactId(contactId);
|
||||
const surveyIds = responses?.map((response) => response.surveyId) || [];
|
||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : ((await getSurveys(environment.id)) ?? []);
|
||||
const session = await getServerSession(authOptions);
|
||||
const t = await getTranslate();
|
||||
|
||||
const t = await getTranslate();
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
@@ -48,19 +40,20 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
return (
|
||||
<ActivityTimeline
|
||||
<ResponseTimeline
|
||||
user={user}
|
||||
surveys={surveys}
|
||||
responses={responses}
|
||||
displays={displays}
|
||||
environment={environment}
|
||||
environmentTags={environmentTags}
|
||||
locale={locale}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { ResponseFeed } from "./response-feed";
|
||||
|
||||
interface ResponseTimelineProps {
|
||||
surveys: TSurvey[];
|
||||
user: TUser;
|
||||
responses: TResponseWithQuotas[];
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
locale: TUserLocale;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const ResponseTimeline = ({
|
||||
surveys,
|
||||
user,
|
||||
environment,
|
||||
responses,
|
||||
environmentTags,
|
||||
locale,
|
||||
projectPermission,
|
||||
}: ResponseTimelineProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [sortedResponses, setSortedResponses] = useState(responses);
|
||||
const toggleSortResponses = () => {
|
||||
setSortedResponses([...sortedResponses].reverse());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSortedResponses(responses);
|
||||
}, [responses]);
|
||||
|
||||
return (
|
||||
// <div className="md:col-span-3">
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">{t("common.responses")}</h2>
|
||||
<div className="text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSortResponses}
|
||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||
<ArrowDownUpIcon className="inline h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ResponseFeed
|
||||
responses={sortedResponses}
|
||||
environment={environment}
|
||||
surveys={surveys}
|
||||
user={user}
|
||||
environmentTags={environmentTags}
|
||||
locale={locale}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { ActivitySection } from "./components/activity-section";
|
||||
import { ResponseSection } from "./components/response-section";
|
||||
|
||||
export const SingleContactPage = async (props: {
|
||||
params: Promise<{ environmentId: string; contactId: string }>;
|
||||
@@ -64,7 +64,7 @@ export const SingleContactPage = async (props: {
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-4 gap-x-8">
|
||||
<AttributesSection contactId={params.contactId} />
|
||||
<ActivitySection
|
||||
<ResponseSection
|
||||
environment={environment}
|
||||
contactId={params.contactId}
|
||||
environmentTags={environmentTags}
|
||||
|
||||
@@ -44,12 +44,12 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
|
||||
const quotas = await getQuotas(surveyId);
|
||||
|
||||
if (!quotas || quotas.length === 0) {
|
||||
return { shouldEndSurvey: false };
|
||||
return { shouldEndSurvey: false, quotaFull: undefined };
|
||||
}
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return { shouldEndSurvey: false };
|
||||
return { shouldEndSurvey: false, quotaFull: undefined };
|
||||
}
|
||||
const isDefaultLanguage = survey.languages.find((lang) => lang.default)?.language.code === language;
|
||||
const result = evaluateQuotas(survey, data, variables, quotas, isDefaultLanguage ? "default" : language);
|
||||
@@ -74,6 +74,6 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, responseId }, "Error evaluating quotas for response");
|
||||
return { shouldEndSurvey: false };
|
||||
return { shouldEndSurvey: false, quotaFull: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
|
||||
import {
|
||||
COLOR_DEFAULTS,
|
||||
STYLE_DEFAULTS,
|
||||
deriveNewFieldsFromLegacy,
|
||||
getSuggestedColors,
|
||||
} from "@/lib/styling/constants";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
||||
@@ -62,11 +67,23 @@ export const ThemeStyling = ({
|
||||
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
|
||||
: {};
|
||||
|
||||
const legacyFills = deriveNewFieldsFromLegacy(cleanSaved);
|
||||
|
||||
const form = useForm<TProjectStyling>({
|
||||
defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved },
|
||||
defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...cleanSaved },
|
||||
resolver: zodResolver(ZProjectStyling),
|
||||
});
|
||||
|
||||
// Brand color shown in the preview. Only updated when the user triggers
|
||||
// "Suggest colors", "Save", or "Reset to default" — NOT on every keystroke
|
||||
// in the brand-color picker. This prevents the loading-spinner / progress
|
||||
// bar from updating while the user is still picking a colour.
|
||||
const [previewBrandColor, setPreviewBrandColor] = useState<string>(
|
||||
(cleanSaved as Partial<TProjectStyling>).brandColor?.light ??
|
||||
STYLE_DEFAULTS.brandColor?.light ??
|
||||
COLOR_DEFAULTS.brandColor
|
||||
);
|
||||
|
||||
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
|
||||
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
|
||||
const [confirmSuggestColorsOpen, setConfirmSuggestColorsOpen] = useState(false);
|
||||
@@ -84,6 +101,7 @@ export const ThemeStyling = ({
|
||||
|
||||
if (updatedProjectResponse?.data) {
|
||||
form.reset({ ...STYLE_DEFAULTS });
|
||||
setPreviewBrandColor(STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
|
||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
@@ -100,6 +118,9 @@ export const ThemeStyling = ({
|
||||
form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true });
|
||||
}
|
||||
|
||||
// Commit brand color to the preview now that all derived colours are in sync.
|
||||
setPreviewBrandColor(brandColor ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
|
||||
|
||||
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
|
||||
setConfirmSuggestColorsOpen(false);
|
||||
};
|
||||
@@ -113,7 +134,11 @@ export const ThemeStyling = ({
|
||||
});
|
||||
|
||||
if (updatedProjectResponse?.data) {
|
||||
form.reset({ ...updatedProjectResponse.data.styling });
|
||||
const saved = updatedProjectResponse.data.styling;
|
||||
form.reset({ ...saved });
|
||||
setPreviewBrandColor(
|
||||
saved?.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor
|
||||
);
|
||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||
@@ -249,7 +274,9 @@ export const ThemeStyling = ({
|
||||
survey={previewSurvey(project.name, t)}
|
||||
project={{
|
||||
...project,
|
||||
styling: form.watch("allowStyleOverwrite") ? form.watch() : STYLE_DEFAULTS,
|
||||
styling: form.watch("allowStyleOverwrite")
|
||||
? { ...form.watch(), brandColor: { light: previewBrandColor } }
|
||||
: STYLE_DEFAULTS,
|
||||
}}
|
||||
previewType={previewSurveyType}
|
||||
setPreviewType={setPreviewSurveyType}
|
||||
|
||||
@@ -9,7 +9,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
|
||||
import { STYLE_DEFAULTS, deriveNewFieldsFromLegacy, getSuggestedColors } from "@/lib/styling/constants";
|
||||
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
|
||||
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
@@ -68,10 +68,15 @@ export const StylingView = ({
|
||||
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
|
||||
: {};
|
||||
|
||||
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject);
|
||||
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
|
||||
|
||||
const form = useForm<TSurveyStyling>({
|
||||
defaultValues: {
|
||||
...STYLE_DEFAULTS,
|
||||
...projectLegacyFills,
|
||||
...cleanProject,
|
||||
...surveyLegacyFills,
|
||||
...cleanSurvey,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +40,10 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
||||
isLoadingScript = true;
|
||||
try {
|
||||
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
|
||||
const response = await fetch(scriptUrl);
|
||||
const response = await fetch(
|
||||
scriptUrl,
|
||||
process.env.NODE_ENV === "development" ? { cache: "no-store" } : {}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load the surveys package");
|
||||
|
||||
@@ -96,6 +96,7 @@ test.describe("Survey Styling", async () => {
|
||||
expect(css).toContain("--fb-input-background-color: #eeeeee");
|
||||
expect(css).toContain("--fb-input-border-color: #cccccc");
|
||||
expect(css).toContain("--fb-input-text-color: #024eff");
|
||||
expect(css).toContain("--fb-input-placeholder-color:");
|
||||
expect(css).toContain("--fb-input-border-radius: 5px");
|
||||
expect(css).toContain("--fb-input-height: 50px");
|
||||
expect(css).toContain("--fb-input-font-size: 16px");
|
||||
|
||||
@@ -4,12 +4,182 @@ description: "Formbricks Self-hosted version migration"
|
||||
icon: "arrow-right"
|
||||
---
|
||||
|
||||
## v4.7
|
||||
|
||||
Formbricks v4.7 introduces **typed contact attributes** with native `number` and `date` data types. This enables comparison-based segment filters (e.g. "signup date before 2025-01-01") that were previously not possible with string-only attribute values.
|
||||
|
||||
### What Happens Automatically
|
||||
|
||||
When Formbricks v4.7 starts for the first time, the data migration will:
|
||||
|
||||
1. Analyze all existing contact attribute keys and infer their data types (`text`, `number`, or `date`) based on the stored values
|
||||
2. Update the `ContactAttributeKey` table with the detected `dataType` for each key
|
||||
3. **If your instance has fewer than 1,000,000 contact attribute rows**: backfill the new `valueNumber` and `valueDate` columns inline. No manual action is needed.
|
||||
4. **If your instance has 1,000,000 or more contact attribute rows**: the value backfill is skipped to avoid hitting the migration timeout. You will need to run a standalone backfill script after the upgrade.
|
||||
|
||||
<Info>
|
||||
Most self-hosted instances have far fewer than 1,000,000 contact attribute rows (a typical setup with 100K
|
||||
contacts and 5-10 attributes each lands around 500K-1M rows). If you are below the threshold, the migration
|
||||
handles everything automatically and you can skip the manual backfill step below.
|
||||
</Info>
|
||||
|
||||
### Steps to Migrate
|
||||
|
||||
**1. Backup your Database**
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker">
|
||||
Before running these steps, navigate to the `formbricks` directory where your `docker-compose.yml` file is located.
|
||||
|
||||
```bash
|
||||
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
<Info>
|
||||
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||
`formbricks_postgres_1`.
|
||||
</Info>
|
||||
</Tab>
|
||||
<Tab title="Kubernetes">
|
||||
If you are using the **in-cluster PostgreSQL** deployed by the Helm chart:
|
||||
|
||||
```bash
|
||||
kubectl exec -n formbricks formbricks-postgresql-0 -- pg_dump -Fc -U formbricks -d formbricks > formbricks_pre_v4.7_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
<Info>
|
||||
If your PostgreSQL pod has a different name, run `kubectl get pods -n formbricks` to find it.
|
||||
</Info>
|
||||
|
||||
If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
**2. Upgrade to Formbricks v4.7**
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker">
|
||||
```bash
|
||||
# Pull the latest version
|
||||
docker compose pull
|
||||
|
||||
# Stop the current instance
|
||||
docker compose down
|
||||
|
||||
# Start with Formbricks v4.7
|
||||
docker compose up -d
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Kubernetes">
|
||||
```bash
|
||||
helm upgrade formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \
|
||||
-n formbricks \
|
||||
--set deployment.image.tag=v4.7.0
|
||||
```
|
||||
|
||||
<Info>
|
||||
The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a
|
||||
PreSync hook before the new pods start. No manual migration step is needed.
|
||||
</Info>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
**3. Check the Migration Logs**
|
||||
|
||||
After Formbricks starts, check the logs to see whether the value backfill was completed or skipped:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker">
|
||||
```bash
|
||||
docker compose logs formbricks | grep -i "backfill"
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Kubernetes">
|
||||
```bash
|
||||
# Check the application pod logs
|
||||
kubectl logs -n formbricks -l app.kubernetes.io/name=formbricks --tail=200 | grep -i "backfill"
|
||||
```
|
||||
|
||||
If the Helm migration Job ran, you can also inspect its logs:
|
||||
|
||||
```bash
|
||||
kubectl logs -n formbricks job/formbricks-migration
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
If you see a message like `Skipping value backfill (X rows >= 1000000 threshold)`, proceed to step 4. Otherwise, the migration is complete and no further action is needed.
|
||||
|
||||
**4. Run the Backfill Script (large datasets only)**
|
||||
|
||||
If the migration skipped the value backfill, run the standalone backfill script inside the running Formbricks container:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker">
|
||||
```bash
|
||||
docker exec formbricks node packages/database/dist/scripts/backfill-attribute-values.js
|
||||
```
|
||||
|
||||
<Info>Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it.</Info>
|
||||
</Tab>
|
||||
<Tab title="Kubernetes">
|
||||
```bash
|
||||
kubectl exec -n formbricks deploy/formbricks -- node packages/database/dist/scripts/backfill-attribute-values.js
|
||||
```
|
||||
|
||||
<Info>
|
||||
If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it.
|
||||
</Info>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
The script will output progress as it runs:
|
||||
|
||||
```
|
||||
========================================
|
||||
Attribute Value Backfill Script
|
||||
========================================
|
||||
|
||||
Fetching number-type attribute keys...
|
||||
Found 12 number-type keys. Backfilling valueNumber...
|
||||
Number backfill progress: 10/12 keys (48230 rows updated)
|
||||
Number backfill progress: 12/12 keys (52104 rows updated)
|
||||
|
||||
Fetching date-type attribute keys...
|
||||
Found 5 date-type keys. Backfilling valueDate...
|
||||
Date backfill progress: 5/5 keys (31200 rows updated)
|
||||
|
||||
========================================
|
||||
Backfill Complete!
|
||||
========================================
|
||||
valueNumber rows updated: 52104
|
||||
valueDate rows updated: 31200
|
||||
Duration: 42.3s
|
||||
========================================
|
||||
```
|
||||
|
||||
Key characteristics of the backfill script:
|
||||
|
||||
- **Safe to run while Formbricks is live** -- it does not lock the entire table or wrap work in a long transaction
|
||||
- **Idempotent** -- it only updates rows where the typed columns are still `NULL`, so you can safely run it multiple times
|
||||
- **Resumable** -- each batch commits independently, so if the process is interrupted you can re-run it and it picks up where it left off
|
||||
- **No timeout risk** -- unlike the migration, this script runs outside the migration transaction and has no time limit
|
||||
|
||||
**5. Verify the Upgrade**
|
||||
|
||||
- Access your Formbricks instance at the same URL as before
|
||||
- If you use contact segments with number or date filters, verify they return the expected results
|
||||
- Check that existing surveys and response data are intact
|
||||
|
||||
---
|
||||
|
||||
## v4.0
|
||||
|
||||
<Warning>
|
||||
**Important: Migration Required**
|
||||
|
||||
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
||||
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
||||
|
||||
</Warning>
|
||||
|
||||
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
|
||||
@@ -17,9 +187,11 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
|
||||
### What's New in Formbricks 4.0
|
||||
|
||||
**🚀 New Enterprise Features:**
|
||||
|
||||
- **Quotas Management**: Advanced quota controls for enterprise users
|
||||
|
||||
**🏗️ Technical Foundation Improvements:**
|
||||
|
||||
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
||||
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
||||
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
||||
@@ -39,7 +211,8 @@ These services are already included in the updated one-click setup for self-host
|
||||
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
|
||||
|
||||
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
|
||||
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
||||
|
||||
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
||||
- **Advanced features** that require sophisticated caching and file processing
|
||||
- **Better performance** through optimized, dedicated services
|
||||
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
|
||||
@@ -52,7 +225,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
|
||||
|
||||
### One-Click Setup
|
||||
|
||||
For users using our official one-click setup, we provide an automated migration using a migration script:
|
||||
For users using our official one-click setup, we provide an automated migration using a migration script:
|
||||
|
||||
```bash
|
||||
# Download the latest script
|
||||
@@ -67,11 +240,11 @@ chmod +x migrate-to-v4.sh
|
||||
```
|
||||
|
||||
This script guides you through the steps for the infrastructure migration and does the following:
|
||||
|
||||
- Adds a Redis service to your setup and configures it
|
||||
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
|
||||
- Pulls the latest Formbricks image and updates your instance
|
||||
|
||||
|
||||
### Manual Setup
|
||||
|
||||
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
|
||||
@@ -87,6 +260,7 @@ You need to configure the `REDIS_URL` environment variable and point it to your
|
||||
To use file storage (e.g., file upload questions, image choice questions, custom survey backgrounds, etc.), you need to have S3-compatible file storage set up and connected to Formbricks.
|
||||
|
||||
Formbricks supports multiple storage providers (among many other S3-compatible storages):
|
||||
|
||||
- AWS S3
|
||||
- Digital Ocean Spaces
|
||||
- Hetzner Object Storage
|
||||
@@ -101,6 +275,7 @@ Please make sure to set up a storage bucket with one of these solutions and then
|
||||
S3_BUCKET_NAME: formbricks-uploads
|
||||
S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3
|
||||
```
|
||||
|
||||
#### Upgrade Process
|
||||
|
||||
**1. Backup your Database**
|
||||
@@ -112,8 +287,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
|
||||
```
|
||||
|
||||
<Info>
|
||||
If you run into "**No such container**", use `docker ps` to find your container name,
|
||||
e.g. `formbricks_postgres_1`.
|
||||
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||
`formbricks_postgres_1`.
|
||||
</Info>
|
||||
|
||||
**2. Upgrade to Formbricks 4.0**
|
||||
@@ -134,6 +309,7 @@ docker compose up -d
|
||||
**3. Automatic Database Migration**
|
||||
|
||||
When you start Formbricks 4.0 for the first time, it will **automatically**:
|
||||
|
||||
- Detect and apply required database schema updates
|
||||
- Remove unused database tables and fields
|
||||
- Optimize the database structure for better performance
|
||||
|
||||
@@ -1,41 +1,94 @@
|
||||
---
|
||||
title: "Rate Limiting"
|
||||
description: "Rate limiting for Formbricks"
|
||||
description: "Current request rate limits in Formbricks"
|
||||
icon: "timer"
|
||||
---
|
||||
|
||||
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
|
||||
Formbricks applies request rate limits to protect against abuse and keep API usage fair.
|
||||
|
||||
## Default Rate Limits
|
||||
Rate limits are scoped by identifier, depending on the endpoint:
|
||||
|
||||
The following rate limits apply to various endpoints:
|
||||
- IP hash (for unauthenticated/client-side routes and public actions)
|
||||
- API key ID (for authenticated API calls)
|
||||
- User ID (for authenticated session-based calls and server actions)
|
||||
- Organization ID (for follow-up email dispatch)
|
||||
|
||||
| **Endpoint** | **Rate Limit** | **Time Window** |
|
||||
| ----------------------- | -------------- | --------------- |
|
||||
| `POST /login` | 30 requests | 15 minutes |
|
||||
| `POST /signup` | 30 requests | 60 minutes |
|
||||
| `POST /verify-email` | 10 requests | 60 minutes |
|
||||
| `POST /forgot-password` | 5 requests | 60 minutes |
|
||||
| `GET /client-side-api` | 100 requests | 1 minute |
|
||||
| `POST /share` | 100 requests | 60 minutes |
|
||||
When a limit is exceeded, the API returns `429 Too Many Requests`.
|
||||
|
||||
If a request exceeds the defined rate limit, the server will respond with:
|
||||
## Management API Rate Limits
|
||||
|
||||
These are the current limits for Management APIs:
|
||||
|
||||
| **Route Group** | **Limit** | **Window** | **Identifier** |
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
|
||||
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
|
||||
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
|
||||
|
||||
## All Enforced Limits
|
||||
|
||||
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
|
||||
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
|
||||
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
|
||||
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
|
||||
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
|
||||
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
|
||||
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
|
||||
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
|
||||
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
|
||||
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
|
||||
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
|
||||
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
|
||||
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
|
||||
|
||||
## Current Endpoint Exceptions
|
||||
|
||||
The following routes are currently not rate-limited by the server-side limiter:
|
||||
|
||||
- `GET /api/v1/client/og` (explicitly excluded)
|
||||
- `POST /api/v2/client/[environmentId]/responses`
|
||||
- `POST /api/v2/client/[environmentId]/displays`
|
||||
- `GET /api/v2/health`
|
||||
|
||||
## 429 Response Shape
|
||||
|
||||
v1-style endpoints return:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 429,
|
||||
"error": "Too many requests, Please try after a while!"
|
||||
"code": "too_many_requests",
|
||||
"message": "Maximum number of requests reached. Please try again later.",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
v2-style endpoints return:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": 429,
|
||||
"message": "Too Many Requests"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Disabling Rate Limiting
|
||||
|
||||
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
|
||||
For self-hosters, rate limiting can be disabled if necessary. We strongly recommend keeping it enabled in production.
|
||||
|
||||
To disable rate limiting, set the following environment variable:
|
||||
Set:
|
||||
|
||||
```bash
|
||||
RATE_LIMITING_DISABLED=1
|
||||
```
|
||||
|
||||
After making this change, restart your server to apply the new setting.
|
||||
After changing this value, restart the server.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
|
||||
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
|
||||
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
|
||||
|
||||
@@ -127,12 +127,12 @@
|
||||
--fb-input-font-size: 14px;
|
||||
--fb-input-font-weight: 400;
|
||||
--fb-input-color: #414b5a;
|
||||
--fb-input-placeholder-color: var(--fb-input-color);
|
||||
--fb-input-placeholder-color: var(--fb-input-text-color, var(--fb-input-color));
|
||||
--fb-input-placeholder-opacity: 0.5;
|
||||
--fb-input-width: 100%;
|
||||
--fb-input-height: 40px;
|
||||
--fb-input-padding-x: 16px;
|
||||
--fb-input-padding-y: 16px;
|
||||
--fb-input-height: 20px;
|
||||
--fb-input-padding-x: 8px;
|
||||
--fb-input-padding-y: 8px;
|
||||
--fb-input-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
|
||||
/* ── Progress Bar ──────────────────────────────────────────────────── */
|
||||
@@ -244,4 +244,4 @@
|
||||
|
||||
#fbjs textarea::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(215.4 16.3% 46.9% / 0.5);
|
||||
}
|
||||
}
|
||||
@@ -443,6 +443,39 @@ describe("addCustomThemeToDom", () => {
|
||||
expect(variables["--fb-button-font-size"]).toBe("1.5rem");
|
||||
});
|
||||
|
||||
test("should derive input-placeholder-color from inputTextColor when set", () => {
|
||||
const styling: TSurveyStyling = {
|
||||
...getBaseProjectStyling(),
|
||||
questionColor: { light: "#AABBCC" },
|
||||
inputTextColor: { light: "#112233" },
|
||||
};
|
||||
addCustomThemeToDom({ styling });
|
||||
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
|
||||
const variables = getCssVariables(styleElement);
|
||||
|
||||
// Placeholder should be derived from inputTextColor, not questionColor
|
||||
expect(variables["--fb-input-placeholder-color"]).toBeDefined();
|
||||
expect(variables["--fb-placeholder-color"]).toBeDefined();
|
||||
// Both should be based on inputTextColor (#112233) mixed with white, not questionColor (#AABBCC)
|
||||
// We can verify by checking the placeholder color doesn't contain the questionColor mix
|
||||
expect(variables["--fb-input-placeholder-color"]).toBe(variables["--fb-placeholder-color"]);
|
||||
});
|
||||
|
||||
test("should derive input-placeholder-color from questionColor when inputTextColor is not set", () => {
|
||||
const styling: TSurveyStyling = {
|
||||
...getBaseProjectStyling(),
|
||||
questionColor: { light: "#AABBCC" },
|
||||
};
|
||||
addCustomThemeToDom({ styling });
|
||||
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
|
||||
const variables = getCssVariables(styleElement);
|
||||
|
||||
// Placeholder should fall back to questionColor when inputTextColor is not set
|
||||
expect(variables["--fb-input-placeholder-color"]).toBeDefined();
|
||||
expect(variables["--fb-placeholder-color"]).toBeDefined();
|
||||
expect(variables["--fb-input-placeholder-color"]).toBe(variables["--fb-placeholder-color"]);
|
||||
});
|
||||
|
||||
test("should set signature and branding text colors for dark questionColor", () => {
|
||||
const styling = getBaseProjectStyling({
|
||||
questionColor: { light: "#202020" }, // A dark color
|
||||
|
||||
@@ -111,8 +111,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
// Backwards-compat: legacy variables still used by some consumers/tests
|
||||
appendCssVariable("subheading-color", styling.questionColor?.light);
|
||||
|
||||
if (styling.questionColor?.light) {
|
||||
appendCssVariable("placeholder-color", mixColor(styling.questionColor.light, "#ffffff", 0.3));
|
||||
const placeholderBaseColor = styling.inputTextColor?.light ?? styling.questionColor?.light;
|
||||
if (placeholderBaseColor) {
|
||||
appendCssVariable("placeholder-color", mixColor(placeholderBaseColor, "#ffffff", 0.3));
|
||||
appendCssVariable("input-placeholder-color", mixColor(placeholderBaseColor, "#ffffff", 0.3));
|
||||
}
|
||||
|
||||
appendCssVariable("border-color", styling.inputBorderColor?.light);
|
||||
@@ -192,8 +194,13 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
}
|
||||
|
||||
// Buttons (Advanced)
|
||||
appendCssVariable("button-bg-color", styling.buttonBgColor?.light);
|
||||
appendCssVariable("button-text-color", styling.buttonTextColor?.light);
|
||||
const buttonBg = styling.buttonBgColor?.light ?? styling.brandColor?.light;
|
||||
let buttonText = styling.buttonTextColor?.light;
|
||||
if (buttonText === undefined && buttonBg) {
|
||||
buttonText = isLight(buttonBg) ? "#0f172a" : "#ffffff";
|
||||
}
|
||||
appendCssVariable("button-bg-color", buttonBg);
|
||||
appendCssVariable("button-text-color", buttonText);
|
||||
if (styling.buttonBorderRadius !== undefined)
|
||||
appendCssVariable("button-border-radius", formatDimension(styling.buttonBorderRadius));
|
||||
if (styling.buttonHeight !== undefined)
|
||||
@@ -209,7 +216,11 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
|
||||
// Inputs (Advanced)
|
||||
appendCssVariable("input-background-color", styling.inputBgColor?.light ?? styling.inputColor?.light);
|
||||
appendCssVariable("input-text-color", styling.inputTextColor?.light);
|
||||
const inputTextColor = styling.inputTextColor?.light ?? styling.questionColor?.light;
|
||||
appendCssVariable("input-text-color", inputTextColor);
|
||||
if (inputTextColor) {
|
||||
appendCssVariable("input-placeholder-color", mixColor(inputTextColor, "#ffffff", 0.3));
|
||||
}
|
||||
if (styling.inputBorderRadius !== undefined)
|
||||
appendCssVariable("input-border-radius", formatDimension(styling.inputBorderRadius));
|
||||
if (styling.inputHeight !== undefined)
|
||||
@@ -225,8 +236,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
appendCssVariable("input-shadow", styling.inputShadow);
|
||||
|
||||
// Options (Advanced)
|
||||
appendCssVariable("option-bg-color", styling.optionBgColor?.light);
|
||||
appendCssVariable("option-label-color", styling.optionLabelColor?.light);
|
||||
appendCssVariable("option-bg-color", styling.optionBgColor?.light ?? styling.inputColor?.light);
|
||||
appendCssVariable("option-label-color", styling.optionLabelColor?.light ?? styling.questionColor?.light);
|
||||
if (styling.optionBorderRadius !== undefined)
|
||||
appendCssVariable("option-border-radius", formatDimension(styling.optionBorderRadius));
|
||||
if (styling.optionPaddingX !== undefined)
|
||||
@@ -277,8 +288,15 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
// Implicitly set the progress track border radius to the roundness of the card
|
||||
appendCssVariable("progress-track-border-radius", formatDimension(roundness));
|
||||
|
||||
appendCssVariable("progress-track-bg-color", styling.progressTrackBgColor?.light);
|
||||
appendCssVariable("progress-indicator-bg-color", styling.progressIndicatorBgColor?.light);
|
||||
appendCssVariable(
|
||||
"progress-track-bg-color",
|
||||
styling.progressTrackBgColor?.light ??
|
||||
(styling.brandColor?.light ? mixColor(styling.brandColor.light, "#ffffff", 0.8) : undefined)
|
||||
);
|
||||
appendCssVariable(
|
||||
"progress-indicator-bg-color",
|
||||
styling.progressIndicatorBgColor?.light ?? styling.brandColor?.light
|
||||
);
|
||||
|
||||
// Close the #fbjs variable block
|
||||
cssVariables += "}\n";
|
||||
@@ -304,7 +322,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
headlineDecls += " font-size: var(--fb-element-headline-font-size) !important;\n";
|
||||
if (styling.elementHeadlineFontWeight !== undefined && styling.elementHeadlineFontWeight !== null)
|
||||
headlineDecls += " font-weight: var(--fb-element-headline-font-weight) !important;\n";
|
||||
if (styling.elementHeadlineColor?.light)
|
||||
if (styling.elementHeadlineColor?.light || styling.questionColor?.light)
|
||||
headlineDecls += " color: var(--fb-element-headline-color) !important;\n";
|
||||
addRule("#fbjs .label-headline,\n#fbjs .label-headline *", headlineDecls);
|
||||
|
||||
@@ -314,7 +332,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
descriptionDecls += " font-size: var(--fb-element-description-font-size) !important;\n";
|
||||
if (styling.elementDescriptionFontWeight !== undefined && styling.elementDescriptionFontWeight !== null)
|
||||
descriptionDecls += " font-weight: var(--fb-element-description-font-weight) !important;\n";
|
||||
if (styling.elementDescriptionColor?.light)
|
||||
if (styling.elementDescriptionColor?.light || styling.questionColor?.light)
|
||||
descriptionDecls += " color: var(--fb-element-description-color) !important;\n";
|
||||
addRule("#fbjs .label-description,\n#fbjs .label-description *", descriptionDecls);
|
||||
|
||||
@@ -324,7 +342,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
upperDecls += " font-size: var(--fb-element-upper-label-font-size) !important;\n";
|
||||
if (styling.elementUpperLabelFontWeight !== undefined && styling.elementUpperLabelFontWeight !== null)
|
||||
upperDecls += " font-weight: var(--fb-element-upper-label-font-weight) !important;\n";
|
||||
if (styling.elementUpperLabelColor?.light) {
|
||||
if (styling.elementUpperLabelColor?.light || styling.questionColor?.light) {
|
||||
upperDecls += " color: var(--fb-element-upper-label-color) !important;\n";
|
||||
upperDecls += " opacity: var(--fb-element-upper-label-opacity, 1) !important;\n";
|
||||
}
|
||||
@@ -332,9 +350,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
|
||||
// --- Buttons ---
|
||||
let buttonDecls = "";
|
||||
if (styling.buttonBgColor?.light)
|
||||
if (styling.buttonBgColor?.light || styling.brandColor?.light)
|
||||
buttonDecls += " background-color: var(--fb-button-bg-color) !important;\n";
|
||||
if (styling.buttonTextColor?.light) buttonDecls += " color: var(--fb-button-text-color) !important;\n";
|
||||
if (styling.buttonTextColor?.light || styling.brandColor?.light)
|
||||
buttonDecls += " color: var(--fb-button-text-color) !important;\n";
|
||||
if (styling.buttonBorderRadius !== undefined)
|
||||
buttonDecls += " border-radius: var(--fb-button-border-radius) !important;\n";
|
||||
if (styling.buttonHeight !== undefined) buttonDecls += " height: var(--fb-button-height) !important;\n";
|
||||
@@ -355,11 +374,11 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
// --- Options ---
|
||||
if (styling.optionBorderRadius !== undefined)
|
||||
addRule("#fbjs .rounded-option", " border-radius: var(--fb-option-border-radius) !important;\n");
|
||||
if (styling.optionBgColor?.light)
|
||||
if (styling.optionBgColor?.light || styling.inputColor?.light)
|
||||
addRule("#fbjs .bg-option-bg", " background-color: var(--fb-option-bg-color) !important;\n");
|
||||
|
||||
let optionLabelDecls = "";
|
||||
if (styling.optionLabelColor?.light)
|
||||
if (styling.optionLabelColor?.light || styling.questionColor?.light)
|
||||
optionLabelDecls += " color: var(--fb-option-label-color) !important;\n";
|
||||
if (styling.optionFontSize !== undefined)
|
||||
optionLabelDecls += " font-size: var(--fb-option-font-size) !important;\n";
|
||||
@@ -385,7 +404,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
addRule("#fbjs .border-input-border", " border-color: var(--fb-input-border-color) !important;\n");
|
||||
|
||||
let inputTextDecls = "";
|
||||
if (styling.inputTextColor?.light) inputTextDecls += " color: var(--fb-input-text-color) !important;\n";
|
||||
if (styling.inputTextColor?.light || styling.questionColor?.light)
|
||||
inputTextDecls += " color: var(--fb-input-text-color) !important;\n";
|
||||
if (styling.inputFontSize !== undefined)
|
||||
inputTextDecls += " font-size: var(--fb-input-font-size) !important;\n";
|
||||
addRule("#fbjs .text-input-text", inputTextDecls);
|
||||
|
||||
@@ -30,17 +30,3 @@ export const ZDisplayFilters = z.object({
|
||||
});
|
||||
|
||||
export type TDisplayFilters = z.infer<typeof ZDisplayFilters>;
|
||||
|
||||
export const ZDisplayWithContact = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
surveyId: z.string(),
|
||||
contact: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
attributes: z.record(z.string()),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type TDisplayWithContact = z.infer<typeof ZDisplayWithContact>;
|
||||
|
||||
Reference in New Issue
Block a user