From 8137de3c803aa524feff232574e0bfda668747e7 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 24 Apr 2026 10:39:38 +0200 Subject: [PATCH 1/6] feat: integrate hub feedback records into unify workspace Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience. Made-with: Cursor --- .../unify/feedback-records/actions.ts | 197 ++++ .../feedback-record-form-drawer.tsx | 988 ++++++++++++++++++ .../feedback-records-page-client.tsx | 22 +- .../feedback-records-table.tsx | 379 ++++--- .../unify/feedback-records/page.tsx | 49 +- apps/web/modules/hub/index.ts | 4 + apps/web/modules/hub/service.test.ts | 2 +- apps/web/modules/hub/service.ts | 57 +- apps/web/modules/hub/types.ts | 1 + 9 files changed, 1509 insertions(+), 190 deletions(-) create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-record-form-drawer.tsx diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts new file mode 100644 index 0000000000..47f97f6b18 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts @@ -0,0 +1,197 @@ +"use server"; + +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; +import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory"; +import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service"; +import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types"; + +const ZFeedbackRecordId = z.uuid(); + +const ZFeedbackRecordFieldType = z.enum([ + "text", + "categorical", + "nps", + "csat", + "ces", + "rating", + "number", + "boolean", + "date", +]); + +const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown()); + +const ZFeedbackRecordCreateInput = z.object({ + submission_id: z.string().min(1), + tenant_id: ZId, + source_type: z.string().min(1), + field_id: z.string().min(1), + field_type: ZFeedbackRecordFieldType, + collected_at: z.iso.datetime().optional(), + source_id: z.string().optional().nullable(), + source_name: z.string().optional().nullable(), + field_label: z.string().optional().nullable(), + field_group_id: z.string().optional(), + field_group_label: z.string().optional().nullable(), + value_text: z.string().optional().nullable(), + value_number: z.number().optional(), + value_boolean: z.boolean().optional(), + value_date: z.iso.datetime().optional(), + metadata: ZFeedbackRecordMetadata.optional(), + language: z.string().optional(), + user_identifier: z.string().optional(), +}); + +const ZFeedbackRecordUpdateInput = z + .object({ + value_text: z.string().optional().nullable(), + value_number: z.number().optional().nullable(), + value_boolean: z.boolean().optional().nullable(), + value_date: z.iso.datetime().optional().nullable(), + language: z.string().optional().nullable(), + metadata: ZFeedbackRecordMetadata.optional(), + user_identifier: z.string().optional().nullable(), + }) + .refine( + (value) => Object.values(value).some((entry) => entry !== undefined), + "At least one field must be provided for update" + ); + +const ZRetrieveFeedbackRecordAction = z.object({ + workspaceId: ZId, + recordId: ZFeedbackRecordId, +}); + +const ZCreateFeedbackRecordAction = z.object({ + workspaceId: ZId, + recordInput: ZFeedbackRecordCreateInput, +}); + +const ZUpdateFeedbackRecordAction = z.object({ + workspaceId: ZId, + recordId: ZFeedbackRecordId, + updateInput: ZFeedbackRecordUpdateInput, +}); + +const ensureAccess = async ( + userId: string, + workspaceId: string, + minPermission: "read" | "readWrite" +): Promise => { + const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); + await checkAuthorizationUpdated({ + userId, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "workspaceTeam", + minPermission, + workspaceId, + }, + ], + }); +}; + +const getWorkspaceDirectoryIds = async (workspaceId: string): Promise> => { + const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId); + return new Set(directories.map((directory) => directory.id)); +}; + +const assertWorkspaceDirectoryAccess = (directoryIds: Set, tenantId: string): void => { + if (!directoryIds.has(tenantId)) { + throw new AuthorizationError("Invalid feedback record directory for this workspace"); + } +}; + +export const retrieveFeedbackRecordAction = authenticatedActionClient + .inputSchema(ZRetrieveFeedbackRecordAction) + .action( + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: z.infer; + }) => { + await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read"); + + const recordResult = await retrieveFeedbackRecord(parsedInput.recordId); + if (!recordResult.data || recordResult.error) { + throw new Error(recordResult.error?.message || "Failed to retrieve feedback record"); + } + + const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId); + assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id); + + return recordResult.data; + } + ); + +export const createFeedbackRecordAction = authenticatedActionClient + .inputSchema(ZCreateFeedbackRecordAction) + .action( + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: z.infer; + }) => { + await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"); + + const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId); + assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id); + + const createResult = await createFeedbackRecord( + parsedInput.recordInput as unknown as FeedbackRecordCreateParams + ); + if (!createResult.data || createResult.error) { + throw new Error(createResult.error?.message || "Failed to create feedback record"); + } + + return createResult.data; + } + ); + +export const updateFeedbackRecordAction = authenticatedActionClient + .inputSchema(ZUpdateFeedbackRecordAction) + .action( + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: z.infer; + }) => { + await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"); + + const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId); + if (!currentRecordResult.data || currentRecordResult.error) { + throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record"); + } + + const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId); + assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id); + + const updatePayload = Object.fromEntries( + Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined) + ) as unknown as FeedbackRecordUpdateParams; + + const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload); + if (!updateResult.data || updateResult.error) { + throw new Error(updateResult.error?.message || "Failed to update feedback record"); + } + + return updateResult.data; + } + ); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-record-form-drawer.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-record-form-drawer.tsx new file mode 100644 index 0000000000..ea8af3f6ed --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-record-form-drawer.tsx @@ -0,0 +1,988 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { PlusIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { v7 as uuidv7 } from "uuid"; +import { z } from "zod"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import type { FeedbackRecordData } from "@/modules/hub/types"; +import { AlertDialog } from "@/modules/ui/components/alert-dialog"; +import { Button } from "@/modules/ui/components/button"; +import { + FormControl, + FormError, + FormField, + FormItem, + FormLabel, + FormProvider, +} from "@/modules/ui/components/form"; +import { Input } from "@/modules/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/modules/ui/components/sheet"; +import { Switch } from "@/modules/ui/components/switch"; +import { + createFeedbackRecordAction, + retrieveFeedbackRecordAction, + updateFeedbackRecordAction, +} from "./actions"; + +type FeedbackRecordDrawerMode = "create" | "edit"; + +interface FeedbackRecordFormDrawerProps { + mode: FeedbackRecordDrawerMode; + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; + directories: { id: string; name: string }[]; + canWrite: boolean; + recordId?: string; + onSuccess: () => Promise | void; +} + +const FIELD_TYPE_OPTIONS = [ + "text", + "categorical", + "nps", + "csat", + "ces", + "rating", + "number", + "boolean", + "date", +] as const; + +const SOURCE_TYPE_PRESET_OPTIONS = [ + "survey", + "review", + "feedback_form", + "support", + "social", + "interview", + "usability_test", + "nps_campaign", +] as const; + +const SOURCE_TYPE_CUSTOM_VALUE = "__custom__"; + +const ZMetadataEntry = z.object({ + key: z.string().trim().min(1), + value: z.string(), +}); + +const ZFeedbackRecordFormValues = z.object({ + id: z.string().optional(), + tenant_id: z.string().min(1), + submission_id: z.string().min(1), + collected_at: z.string().min(1), + created_at: z.string().optional(), + updated_at: z.string().optional(), + source_type: z.string().min(1), + source_id: z.string().optional(), + source_name: z.string().optional(), + field_id: z.string().min(1), + field_label: z.string().optional(), + field_type: z.enum(FIELD_TYPE_OPTIONS), + field_group_id: z.string().optional(), + field_group_label: z.string().optional(), + value_text: z.string().optional(), + value_number: z.string().optional(), + value_boolean: z.boolean().optional(), + value_date: z.string().optional(), + language: z.string().optional(), + user_identifier: z.string().optional(), + metadataEntries: z.array(ZMetadataEntry), +}); + +type TFeedbackRecordFormValues = z.infer; + +const getValueFieldByType = ( + fieldType: TFeedbackRecordFormValues["field_type"] +): "value_text" | "value_number" | "value_boolean" | "value_date" => { + switch (fieldType) { + case "boolean": + return "value_boolean"; + case "date": + return "value_date"; + case "nps": + case "csat": + case "ces": + case "rating": + case "number": + return "value_number"; + default: + return "value_text"; + } +}; + +const toLocalDateTimeInput = (isoDate: string): string => { + const date = new Date(isoDate); + if (!Number.isFinite(date.getTime())) { + return ""; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + + return `${year}-${month}-${day}T${hours}:${minutes}`; +}; + +const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => { + if (!dateTimeValue) { + return undefined; + } + + const parsed = new Date(dateTimeValue); + if (!Number.isFinite(parsed.getTime())) { + return undefined; + } + + return parsed.toISOString(); +}; + +const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => { + const now = new Date(); + const defaultDirectoryId = directories[0]?.id ?? ""; + + return { + id: "", + tenant_id: defaultDirectoryId, + submission_id: uuidv7(), + collected_at: toLocalDateTimeInput(now.toISOString()), + created_at: "", + updated_at: "", + source_type: "survey", + source_id: "", + source_name: "", + field_id: "", + field_label: "", + field_type: "text", + field_group_id: "", + field_group_label: "", + value_text: "", + value_number: "", + value_boolean: undefined, + value_date: "", + language: "", + user_identifier: "", + metadataEntries: [], + }; +}; + +const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => { + const metadataEntries = Object.entries(record.metadata ?? {}) + .filter(([, value]) => typeof value === "string") + .map(([key, value]) => ({ + key, + value: value as string, + })); + + return { + id: record.id, + tenant_id: record.tenant_id, + submission_id: record.submission_id, + collected_at: toLocalDateTimeInput(record.collected_at), + created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "", + updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "", + source_type: record.source_type, + source_id: record.source_id ?? "", + source_name: record.source_name ?? "", + field_id: record.field_id, + field_label: record.field_label ?? "", + field_type: record.field_type, + field_group_id: record.field_group_id ?? "", + field_group_label: record.field_group_label ?? "", + value_text: record.value_text ?? "", + value_number: record.value_number == null ? "" : String(record.value_number), + value_boolean: record.value_boolean, + value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "", + language: record.language ?? "", + user_identifier: record.user_identifier ?? "", + metadataEntries, + }; +}; + +const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => { + return Object.entries(record.metadata ?? {}) + .filter(([, value]) => typeof value !== "string") + .map(([key, value]) => ({ + key, + value: JSON.stringify(value), + })); +}; + +const parseNumberValue = (value: string): number | null => { + if (value.trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const formatSourceType = (sourceType: string, t: (key: string) => string): string => { + switch (sourceType) { + case "formbricks": + case "formbricks_survey": + return t("workspace.unify.formbricks_surveys"); + case "csv": + return t("workspace.unify.csv_import"); + default: + return sourceType; + } +}; + +export const FeedbackRecordFormDrawer = ({ + mode, + open, + onOpenChange, + workspaceId, + directories, + canWrite, + recordId, + onSuccess, +}: Readonly) => { + const { t } = useTranslation(); + const [record, setRecord] = useState(null); + const [isLoadingRecord, setIsLoadingRecord] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false); + + const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]); + + const form = useForm({ + resolver: zodResolver(ZFeedbackRecordFormValues), + defaultValues, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "metadataEntries", + }); + + const fieldType = form.watch("field_type"); + const selectedValueField = getValueFieldByType(fieldType); + const isEditMode = mode === "edit"; + const isReadOnly = isEditMode && !canWrite; + + const [sourceTypeMode, setSourceTypeMode] = useState("survey"); + const [customSourceType, setCustomSourceType] = useState(""); + + const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]); + + const resetForCreate = useCallback(() => { + const nextDefaults = getCreateDefaults(directories); + form.reset(nextDefaults); + setRecord(null); + setSourceTypeMode(nextDefaults.source_type); + setCustomSourceType(""); + }, [directories, form]); + + useEffect(() => { + if (!open) return; + + if (mode === "create") { + resetForCreate(); + return; + } + + if (!recordId) return; + + const loadRecord = async () => { + setIsLoadingRecord(true); + const result = await retrieveFeedbackRecordAction({ workspaceId, recordId }); + + if (!result?.data) { + toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records")); + setIsLoadingRecord(false); + return; + } + + setRecord(result.data); + form.reset(mapRecordToValues(result.data)); + setSourceTypeMode( + SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) + ? result.data.source_type + : SOURCE_TYPE_CUSTOM_VALUE + ); + setCustomSourceType( + SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type + ); + setIsLoadingRecord(false); + }; + + void loadRecord(); + }, [form, mode, open, recordId, resetForCreate, t, workspaceId]); + + const requestClose = useCallback(() => { + if (form.formState.isDirty && !isSubmitting) { + setIsDiscardDialogOpen(true); + return; + } + + onOpenChange(false); + }, [form.formState.isDirty, isSubmitting, onOpenChange]); + + const handleDrawerOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + onOpenChange(true); + return; + } + + requestClose(); + }, + [onOpenChange, requestClose] + ); + + const handleDiscardChanges = () => { + setIsDiscardDialogOpen(false); + onOpenChange(false); + }; + + const setStrictValueValidationError = (message: string) => { + form.setError(selectedValueField, { type: "manual", message }); + }; + + const handleSubmit = form.handleSubmit(async (values) => { + form.clearErrors(); + + if (mode === "create") { + const requiredValueError = t("workspace.unify.feedback_record_value_required"); + if (selectedValueField === "value_text" && !values.value_text?.trim()) { + setStrictValueValidationError(requiredValueError); + return; + } + if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) { + setStrictValueValidationError(requiredValueError); + return; + } + if (selectedValueField === "value_boolean" && values.value_boolean === undefined) { + setStrictValueValidationError(requiredValueError); + return; + } + if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) { + setStrictValueValidationError(requiredValueError); + return; + } + } + + const metadata = Object.fromEntries( + values.metadataEntries + .map((entry) => ({ + key: entry.key.trim(), + value: entry.value, + })) + .filter((entry) => entry.key.length > 0) + .map((entry) => [entry.key, entry.value]) + ); + + setIsSubmitting(true); + + try { + if (mode === "create") { + const sourceTypeValue = + sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type; + + const createResult = await createFeedbackRecordAction({ + workspaceId, + recordInput: { + submission_id: values.submission_id.trim(), + tenant_id: values.tenant_id, + source_type: sourceTypeValue, + source_id: values.source_id?.trim() ? values.source_id.trim() : null, + source_name: values.source_name?.trim() ? values.source_name.trim() : null, + field_id: values.field_id.trim(), + field_label: values.field_label?.trim() ? values.field_label.trim() : null, + field_type: values.field_type, + field_group_id: values.field_group_id?.trim() || undefined, + field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null, + collected_at: toISOOrUndefined(values.collected_at), + value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null, + value_number: + selectedValueField === "value_number" + ? (parseNumberValue(values.value_number ?? "") ?? undefined) + : undefined, + value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined, + value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + language: values.language?.trim() || undefined, + user_identifier: values.user_identifier?.trim() || undefined, + }, + }); + + if (!createResult?.data) { + toast.error(getFormattedErrorMessage(createResult)); + setIsSubmitting(false); + return; + } + } else { + if (!recordId) { + setIsSubmitting(false); + return; + } + + const preservedMetadata = Object.fromEntries( + Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string") + ); + + const updatePayload: Record = { + language: values.language?.trim() || null, + user_identifier: values.user_identifier?.trim() || null, + metadata: { ...preservedMetadata, ...metadata }, + }; + + if (selectedValueField === "value_text") { + updatePayload.value_text = values.value_text?.trim() ?? ""; + } else if (selectedValueField === "value_number") { + updatePayload.value_number = parseNumberValue(values.value_number ?? ""); + } else if (selectedValueField === "value_boolean") { + updatePayload.value_boolean = values.value_boolean ?? null; + } else if (selectedValueField === "value_date") { + updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null; + } + + const updateResult = await updateFeedbackRecordAction({ + workspaceId, + recordId, + updateInput: updatePayload as never, + }); + + if (!updateResult?.data) { + toast.error(getFormattedErrorMessage(updateResult)); + setIsSubmitting(false); + return; + } + } + + toast.success( + mode === "create" + ? t("workspace.unify.feedback_record_created_successfully") + : t("workspace.unify.feedback_record_updated_successfully") + ); + await onSuccess(); + onOpenChange(false); + } finally { + setIsSubmitting(false); + } + }); + + const drawerTitle = + mode === "create" + ? t("workspace.unify.add_feedback_record") + : t("workspace.unify.feedback_record_details"); + + const drawerDescription = + mode === "create" + ? t("workspace.unify.add_feedback_record_description") + : t("workspace.unify.feedback_record_details_description"); + + const valueBooleanStatus = form.watch("value_boolean"); + let valueBooleanLabel = t("common.not_set"); + if (valueBooleanStatus === true) { + valueBooleanLabel = t("common.yes"); + } else if (valueBooleanStatus === false) { + valueBooleanLabel = t("common.no"); + } + + return ( + <> + + + + {drawerTitle} + {drawerDescription} + + + {isLoadingRecord ? ( +
{t("common.loading")}
+ ) : ( + +
+
+ ( + + {t("common.id")} + + + + + )} + /> + ( + + {t("workspace.unify.feedback_record_directory")} + + + + + + )} + /> +
+ +
+ ( + + {t("workspace.unify.submission_id")} + + + + + + )} + /> + ( + + {t("workspace.unify.collected_at")} + + + + + + )} + /> +
+ +
+ ( + + {t("common.created_at")} + + + + + )} + /> + ( + + {t("workspace.unify.updated_at")} + + + + + )} + /> +
+ + {isEditMode ? ( + ( + + {t("workspace.unify.source_type")} + + + + + )} + /> + ) : ( +
+ {t("workspace.unify.source_type")} + + {sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && ( + { + setCustomSourceType(event.target.value); + form.setValue("source_type", event.target.value, { shouldDirty: true }); + }} + placeholder={t("workspace.unify.custom_source_type_placeholder")} + disabled={!canWrite} + /> + )} + {form.formState.errors.source_type?.message} +
+ )} + +
+ ( + + {t("workspace.unify.source_id")} + + + + + )} + /> + ( + + {t("workspace.unify.source_name")} + + + + + )} + /> +
+ +
+ ( + + {t("workspace.unify.field_id")} + + + + + + )} + /> + ( + + {t("workspace.unify.field_label")} + + + + + )} + /> +
+ +
+ ( + + {t("workspace.unify.field_type")} + + + + + )} + /> + ( + + {t("workspace.unify.field_group_id")} + + + + + )} + /> +
+ + ( + + {t("workspace.unify.field_group_label")} + + + + + )} + /> + + ( + + {t("workspace.unify.value_text")} + + + + + )} + /> + +
+ ( + + {t("workspace.unify.value_number")} + + + + + )} + /> + ( + + {t("workspace.unify.value_date")} + + + + + )} + /> +
+ + ( + + {t("workspace.unify.value_boolean")} + +
+ field.onChange(checked)} + disabled={selectedValueField !== "value_boolean" || isReadOnly || !canWrite} + /> + {valueBooleanLabel} +
+
+
+ )} + /> + {form.formState.errors[selectedValueField]?.message} + +
+ ( + + {t("common.language")} + + + + + )} + /> + ( + + {t("workspace.unify.user_identifier")} + + + + + )} + /> +
+ +
+
+ {t("workspace.unify.metadata")} + {canWrite && !isReadOnly && ( + + )} +
+ +
+ {fields.map((field, index) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + {canWrite && !isReadOnly && ( + + )} +
+ ))} +
+ + {readOnlyMetadataEntries.length > 0 && ( +
+

+ {t("workspace.unify.metadata_read_only_entries")} +

+ {readOnlyMetadataEntries.map((entry) => ( +
+ {entry.key} + + {entry.value} + +
+ ))} +
+ )} +
+ +
+ )} + + + + {canWrite && ( + + )} + +
+
+ + setIsDiscardDialogOpen(false)} + onConfirm={handleDiscardChanges} + /> + + ); +}; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-page-client.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-page-client.tsx index e4ea696edb..6a93a84e23 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-page-client.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-page-client.tsx @@ -9,33 +9,33 @@ import { FeedbackRecordsTable } from "./feedback-records-table"; interface FeedbackRecordsPageClientProps { workspaceId: string; - directories: { id: string; name: string }[]; - initialFrdId: string | null; initialRecords: FeedbackRecordData[]; - initialNextCursor?: string; + frdMap: Record; + csvSources: { id: string; name: string }[]; + canWrite: boolean; } export function FeedbackRecordsPageClient({ workspaceId, - directories, - initialFrdId, initialRecords, - initialNextCursor, -}: FeedbackRecordsPageClientProps) { + frdMap, + csvSources, + canWrite, +}: Readonly) { const { t } = useTranslation(); return ( - + ); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx index fa62976605..c1e7e713a5 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx @@ -3,13 +3,16 @@ import { TFunction } from "i18next"; import { CalendarIcon, + ChevronDownIcon, HashIcon, MessageSquareTextIcon, + PlusIcon, RefreshCwIcon, ToggleLeftIcon, TypeIcon, } from "lucide-react"; -import { useCallback, useState } from "react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { listFeedbackRecordsAction } from "@/lib/connector/actions"; @@ -18,15 +21,16 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper"; import type { FeedbackRecordData } from "@/modules/hub/types"; import { Badge } from "@/modules/ui/components/badge"; import { Button } from "@/modules/ui/components/button"; -import { Label } from "@/modules/ui/components/label"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/modules/ui/components/select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; +import { CsvImportModal } from "../sources/components/csv-import-modal"; +import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer"; const RECORDS_PER_PAGE = 50; @@ -50,6 +54,18 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): return "—"; }; +const formatSourceType = (sourceType: string, t: TFunction): string => { + switch (sourceType) { + case "formbricks": + case "formbricks_survey": + return t("workspace.unify.formbricks_surveys"); + case "csv": + return t("workspace.unify.csv_import"); + default: + return sourceType; + } +}; + function truncate(str: string, maxLen: number): string { if (str.length <= maxLen) return str; return str.slice(0, maxLen) + "…"; @@ -57,96 +73,76 @@ function truncate(str: string, maxLen: number): string { interface FeedbackRecordsTableProps { workspaceId: string; - directories: { id: string; name: string }[]; - initialFrdId: string | null; initialRecords: FeedbackRecordData[]; - initialNextCursor?: string; + frdMap: Record; + csvSources: { id: string; name: string }[]; + canWrite: boolean; } export const FeedbackRecordsTable = ({ workspaceId, - directories, - initialFrdId, initialRecords, - initialNextCursor, -}: FeedbackRecordsTableProps) => { + frdMap, + csvSources, + canWrite, +}: Readonly) => { const { t, i18n } = useTranslation(); - const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US"; - const [selectedFrdId, setSelectedFrdId] = useState(initialFrdId); const [records, setRecords] = useState(initialRecords); - const [nextCursor, setNextCursor] = useState(initialNextCursor); const [isRefreshing, setIsRefreshing] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); + const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit"); + const [drawerRecordId, setDrawerRecordId] = useState(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null); - const fetchRecords = useCallback( - async (frdId: string, cursor: string | undefined, append: boolean): Promise => { - const setLoading = append ? setIsLoadingMore : setIsRefreshing; - setLoading(true); - setError(null); - - const result = await listFeedbackRecordsAction({ - workspaceId, - frdId, - limit: RECORDS_PER_PAGE, - cursor, - }); - - if (!result?.data) { - const message = - getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"); - setError(message); - setLoading(false); - return message; - } - - const response = result.data; - setRecords((prev) => (append ? [...prev, ...response.data] : response.data)); - setNextCursor(response.next_cursor); - setLoading(false); - return null; - }, - [workspaceId, t] + const directories = useMemo( + () => + Object.entries(frdMap) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)), + [frdMap] ); + const feedbackDirectoryName = useMemo(() => { + const directoryNames = Array.from( + new Set( + records + .map((record) => frdMap[record.tenant_id]) + .filter((directoryName): directoryName is string => Boolean(directoryName)) + ) + ); - const handleFrdChange = (frdId: string) => { - setSelectedFrdId(frdId); - setRecords([]); - setNextCursor(undefined); - fetchRecords(frdId, undefined, false); - }; + if (directoryNames.length > 0) { + return directoryNames.join(", "); + } - const handleLoadMore = () => { - if (!selectedFrdId) return; - fetchRecords(selectedFrdId, nextCursor, true); - }; + return directories[0]?.name ?? "—"; + }, [directories, frdMap, records]); const handleRefresh = async () => { - if (!selectedFrdId || isRefreshing) return; + if (isRefreshing) return; + setIsRefreshing(true); + setError(null); + const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records")); - const errorMessage = await fetchRecords(selectedFrdId, undefined, false); - if (errorMessage) { - toast.error(errorMessage, { id: toastId }); + + const result = await listFeedbackRecordsAction({ + workspaceId, + limit: RECORDS_PER_PAGE, + }); + + if (!result?.data) { + toast.error(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"), { + id: toastId, + }); + setIsRefreshing(false); return; } + + setRecords(result.data.data); + setIsRefreshing(false); toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId }); }; - const hasMore = !!nextCursor; - const isEmpty = records.length === 0 && !isRefreshing; - const currentFrdName = directories.find((d) => d.id === selectedFrdId)?.name ?? "—"; - - if (directories.length === 0) { - return ( -
- -

- {t("workspace.unify.no_feedback_record_directory_available")} -

-
- ); - } - if (error) { return (
@@ -161,114 +157,195 @@ export const FeedbackRecordsTable = ({ ); } - return ( -
-
-
- - {directories.length === 1 ? ( -

{currentFrdName}

- ) : ( - - )} -
+ const isEmpty = records.length === 0 && !isRefreshing; -
- {!isEmpty && ( + const openEditDrawer = (recordId: string) => { + setDrawerMode("edit"); + setDrawerRecordId(recordId); + setIsDrawerOpen(true); + }; + + const openCreateDrawer = () => { + setDrawerMode("create"); + setDrawerRecordId(undefined); + setIsDrawerOpen(true); + }; + + const hasCsvSources = csvSources.length > 0; + + return ( + <> +
+
+ {isEmpty ? ( + + ) : (

- {t("workspace.unify.showing_count_loaded", { count: records.length })} + {t("workspace.unify.showing_count_loaded", { + count: records.length, + directoryName: feedbackDirectoryName, + })}

)} - +
+ {canWrite && + (hasCsvSources ? ( + + + + + + + {t("workspace.unify.add_feedback_record")} + + + {csvSources.map((source) => ( + { + setCsvImportSource(source); + }}> + {t("workspace.unify.import_via_source_name", { sourceName: source.name })} + + ))} + + + ) : ( + + ))} + + +
-
-
-
- - - - - - - - - - - - - {isEmpty ? ( - - - +
+
+
{t("workspace.unify.collected_at")}{t("workspace.unify.source_type")}{t("workspace.unify.source_name")}{t("workspace.unify.field_label")}{t("workspace.unify.field_type")}{t("workspace.unify.value")}{t("workspace.unify.user_identifier")}
-
-

{t("workspace.unify.no_feedback_records")}

-
-
+ + + + + + + + + - - ) : ( - - {records.map((record) => ( - - ))} - - )} -
{t("workspace.unify.collected_at")}{t("workspace.unify.source_type")}{t("workspace.unify.source_name")}{t("workspace.unify.field_label")}{t("workspace.unify.field_type")}{t("workspace.unify.value")}{t("workspace.unify.user_identifier")}
+ + {isEmpty ? ( + + + +
+

{t("workspace.unify.no_feedback_records")}

+
+ + + + ) : ( + + {records.map((record) => ( + openEditDrawer(record.id)} + /> + ))} + + )} + +
- {hasMore && ( -
- -
+ + + {csvImportSource && ( + { + if (!open) { + setCsvImportSource(null); + } + }} + connectorId={csvImportSource.id} + workspaceId={workspaceId} + /> )} -
+ ); }; const FeedbackRecordRow = ({ record, + workspaceId, locale, t, + onClick, }: { record: FeedbackRecordData; + workspaceId: string; locale: string; t: TFunction; + onClick: () => void; }) => { const value = formatValue(record, t, locale); const isLongValue = value.length > 60; + const isFormbricksSurveySource = + (record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id; + const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`; return ( - + {formatDateTimeForDisplay(new Date(record.collected_at), locale)} - + - {record.source_name ?? "—"} + {isFormbricksSurveySource ? ( + event.stopPropagation()}> + {record.source_name ?? "—"} + + ) : ( + {record.source_name ?? "—"} + )} {record.field_label ?? record.field_id} diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx index 9f4b127e8c..218e695e84 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx @@ -1,16 +1,16 @@ import { notFound } from "next/navigation"; +import { getConnectorsWithMappings } from "@/lib/connector/service"; import { getTranslate } from "@/lingodotdev/server"; import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory"; -import { FeedbackRecordListResponse } from "@/modules/hub"; import { listFeedbackRecords } from "@/modules/hub/service"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; import { FeedbackRecordsPageClient } from "./feedback-records-page-client"; -const INITIAL_PAGE_SIZE = 10; +const INITIAL_PAGE_SIZE = 50; -export default async function UnifyFeedbackRecordsPage(props: { - readonly params: Promise<{ workspaceId: string }>; -}) { +export default async function UnifyFeedbackRecordsPage( + props: Readonly<{ params: Promise<{ workspaceId: string }> }> +) { const t = await getTranslate(); const params = await props.params; @@ -22,31 +22,40 @@ export default async function UnifyFeedbackRecordsPage(props: { } const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess; + const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess; if (!hasAccess) { return notFound(); } - const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId); + const [frds, connectors] = await Promise.all([ + getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId), + getConnectorsWithMappings(params.workspaceId), + ]); - // Preload first FRD's records server-side for fast initial render - const initialFrdId = frds[0]?.id; - let initialRecords: FeedbackRecordListResponse | null = null; + const results = await Promise.all( + frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE })) + ); - if (initialFrdId) { - const result = await listFeedbackRecords({ tenant_id: initialFrdId, limit: INITIAL_PAGE_SIZE }); - // Don't crash if Hub is down — show empty state - if (!result.error) { - initialRecords = result.data; - } - } + // Don't crash if Hub is unreachable — show empty state + const successfulResults = results.filter((r) => !r.error); + + const merged = successfulResults + .flatMap((r) => r.data?.data ?? []) + .sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1)) + .slice(0, INITIAL_PAGE_SIZE); + + const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name])); + const csvSources = connectors + .filter((connector) => connector.type === "csv") + .map((connector) => ({ id: connector.id, name: connector.name })); return ( ); } diff --git a/apps/web/modules/hub/index.ts b/apps/web/modules/hub/index.ts index a3086fcf02..ff66407205 100644 --- a/apps/web/modules/hub/index.ts +++ b/apps/web/modules/hub/index.ts @@ -3,7 +3,10 @@ export { createFeedbackRecord, createFeedbackRecordsBatch, listFeedbackRecords, + retrieveFeedbackRecord, + updateFeedbackRecord, type CreateFeedbackRecordResult, + type HubFeedbackRecordResult, type ListFeedbackRecordsResult, } from "./service"; export type { @@ -11,4 +14,5 @@ export type { FeedbackRecordData, FeedbackRecordListParams, FeedbackRecordListResponse, + FeedbackRecordUpdateParams, } from "./types"; diff --git a/apps/web/modules/hub/service.test.ts b/apps/web/modules/hub/service.test.ts index d8d9e7afb1..d31df7ba2c 100644 --- a/apps/web/modules/hub/service.test.ts +++ b/apps/web/modules/hub/service.test.ts @@ -15,7 +15,7 @@ const { getHubClient } = await import("./hub-client"); const sampleInput: FeedbackRecordCreateParams = { field_id: "el-1", field_type: "rating", - source_type: "formbricks", + source_type: "formbricks_survey", source_id: "survey-1", source_name: "Test Survey", field_label: "Question?", diff --git a/apps/web/modules/hub/service.ts b/apps/web/modules/hub/service.ts index 0babadedeb..d5070ac620 100644 --- a/apps/web/modules/hub/service.ts +++ b/apps/web/modules/hub/service.ts @@ -7,12 +7,16 @@ import type { FeedbackRecordData, FeedbackRecordListParams, FeedbackRecordListResponse, + FeedbackRecordUpdateParams, } from "./types"; -export type CreateFeedbackRecordResult = { +type HubError = { status: number; message: string; detail: string }; + +export type HubFeedbackRecordResult = { data: FeedbackRecordData | null; - error: { status: number; message: string; detail: string } | null; + error: HubError | null; }; +export type CreateFeedbackRecordResult = HubFeedbackRecordResult; const NO_CONFIG_ERROR = { status: 0, @@ -20,7 +24,7 @@ const NO_CONFIG_ERROR = { detail: "HUB_API_KEY is not set; Hub integration is disabled.", } as const; -const createResultFromError = (err: unknown): CreateFeedbackRecordResult => { +const createResultFromError = (err: unknown): HubFeedbackRecordResult => { const status = err instanceof FormbricksHub.APIError ? err.status : 0; const message = err instanceof Error ? err.message : String(err); return { data: null, error: { status, message, detail: message } }; @@ -32,7 +36,7 @@ const createResultFromError = (err: unknown): CreateFeedbackRecordResult => { */ export const createFeedbackRecord = async ( input: FeedbackRecordCreateParams -): Promise => { +): Promise => { const client = getHubClient(); if (!client) { return { data: null, error: { ...NO_CONFIG_ERROR } }; @@ -46,9 +50,48 @@ export const createFeedbackRecord = async ( } }; +/** + * Retrieve a single feedback record from the Hub by id. + */ +export const retrieveFeedbackRecord = async (id: string): Promise => { + const client = getHubClient(); + if (!client) { + return { data: null, error: { ...NO_CONFIG_ERROR } }; + } + + try { + const data = await client.feedbackRecords.retrieve(id); + return { data, error: null }; + } catch (err) { + logger.warn({ err, id }, "Hub: retrieveFeedbackRecord failed"); + return createResultFromError(err); + } +}; + +/** + * Update a single feedback record in the Hub by id. + */ +export const updateFeedbackRecord = async ( + id: string, + input: FeedbackRecordUpdateParams +): Promise => { + const client = getHubClient(); + if (!client) { + return { data: null, error: { ...NO_CONFIG_ERROR } }; + } + + try { + const data = await client.feedbackRecords.update(id, input); + return { data, error: null }; + } catch (err) { + logger.warn({ err, id }, "Hub: updateFeedbackRecord failed"); + return createResultFromError(err); + } +}; + export type ListFeedbackRecordsResult = { data: FeedbackRecordListResponse | null; - error: { status: number; message: string; detail: string } | null; + error: HubError | null; }; /** @@ -78,7 +121,7 @@ export const listFeedbackRecords = async ( */ export const createFeedbackRecordsBatch = async ( inputs: FeedbackRecordCreateParams[] -): Promise<{ results: CreateFeedbackRecordResult[] }> => { +): Promise<{ results: HubFeedbackRecordResult[] }> => { const client = getHubClient(); if (!client) { return { @@ -90,7 +133,7 @@ export const createFeedbackRecordsBatch = async ( inputs.map(async (input) => { try { const data = await client.feedbackRecords.create(input); - return { data, error: null as CreateFeedbackRecordResult["error"] }; + return { data, error: null as HubFeedbackRecordResult["error"] }; } catch (err) { logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed"); return createResultFromError(err); diff --git a/apps/web/modules/hub/types.ts b/apps/web/modules/hub/types.ts index 3fe84699cd..eb22e799c0 100644 --- a/apps/web/modules/hub/types.ts +++ b/apps/web/modules/hub/types.ts @@ -4,3 +4,4 @@ export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParam export type FeedbackRecordData = FormbricksHub.FeedbackRecordData; export type FeedbackRecordListParams = FormbricksHub.FeedbackRecordListParams; export type FeedbackRecordListResponse = FormbricksHub.FeedbackRecordListResponse; +export type FeedbackRecordUpdateParams = FormbricksHub.FeedbackRecordUpdateParams; From 3e6f81268d3ae04eb5631e6f41ad3c3ee02f4ba8 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 26 Apr 2026 18:14:46 +0200 Subject: [PATCH 2/6] fix: require frdId when refreshing feedback records Made-with: Cursor --- .../feedback-records-table.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx index c1e7e713a5..c870a4a893 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx @@ -124,21 +124,33 @@ export const FeedbackRecordsTable = ({ setError(null); const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records")); + const directoryIds = Object.keys(frdMap); + const results = await Promise.all( + directoryIds.map((frdId) => + listFeedbackRecordsAction({ + workspaceId, + frdId, + limit: RECORDS_PER_PAGE, + }) + ) + ); - const result = await listFeedbackRecordsAction({ - workspaceId, - limit: RECORDS_PER_PAGE, - }); + const successfulRecords = results.flatMap((result) => result?.data?.data ?? []); - if (!result?.data) { - toast.error(getFormattedErrorMessage(result) ?? t("workspace.unify.failed_to_load_feedback_records"), { + if (directoryIds.length > 0 && successfulRecords.length === 0) { + const firstErrorResult = results.find((result) => !result?.data); + const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined; + toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), { id: toastId, }); setIsRefreshing(false); return; } - setRecords(result.data.data); + const mergedRecords = successfulRecords + .sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1)) + .slice(0, RECORDS_PER_PAGE); + setRecords(mergedRecords); setIsRefreshing(false); toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId }); }; From ce4d9350e227c6078fbf45973e2f214eae6fbdda Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 26 Apr 2026 20:05:49 +0200 Subject: [PATCH 3/6] fix: add missing feedback record translation keys in PR4 Made-with: Cursor --- apps/web/locales/de-DE.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/en-US.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/es-ES.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/fr-FR.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/hu-HU.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/ja-JP.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/nl-NL.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/pt-BR.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/pt-PT.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/ro-RO.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/ru-RU.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/sv-SE.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/tr-TR.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/zh-Hans-CN.json | 31 ++++++++++++++++++++++++++++++- apps/web/locales/zh-Hant-TW.json | 31 ++++++++++++++++++++++++++++++- 15 files changed, 450 insertions(+), 15 deletions(-) diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index cb8b67ec20..86d0c031f2 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -331,6 +331,7 @@ "not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.", "not_authorized": "Nicht autorisiert", "not_connected": "Nicht verbunden", + "not_set": "Nicht festgelegt", "note": "Hinweis", "notifications": "Benachrichtigungen", "number": "Nummer", @@ -3618,12 +3619,15 @@ "team_settings_description": "Sieh nach, welche Teams auf diesen Workspace zugreifen können." }, "unify": { + "add_feedback_record": "Feedback-Datensatz hinzufügen", + "add_feedback_record_description": "Erstellen Sie manuell einen Feedback-Datensatz.", "add_feedback_source": "Feedback-Quelle hinzufügen", "add_source": "Quelle hinzufügen", "allowed_values": "Zulässige Werte: {values}", "api_ingestion": "API-Erfassung", "api_ingestion_manage_api_keys": "API-Schlüssel verwalten", "api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.", + "auto_generated": "Automatisch generiert", "change_file": "Datei ändern", "click_load_sample_csv": "Klick auf 'Beispiel-CSV laden', um Spalten zu sehen", "click_to_upload": "Zum Hochladen klicken", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Wenn Du die Daten zweimal importierst, entstehen doppelte Einträge.", "csv_inconsistent_columns": "Zeile {row} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.", "csv_max_records": "Maximal {max} Einträge erlaubt.", + "custom_source_type": "Benutzerdefinierter Quelltyp", + "custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein", "default_connector_name_csv": "CSV-Import", "default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung", + "discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.", + "discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?", "drop_a_field_here": "Ziehe ein Feld hierher", "drop_field_or": "Feld ablegen oder", "edit_csv_mapping": "CSV-Zuordnung bearbeiten", @@ -3659,15 +3667,23 @@ "enum": "Aufzählung", "failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden", "feedback_date": "Aktuelles Datum", + "feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt", + "feedback_record_details": "Details zum Feedback-Datensatz", + "feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.", "feedback_record_directory": "Feedback-Datensatz-Verzeichnis", "feedback_record_fields": "Feedback-Eintragsfelder", "feedback_record_mcp": "Feedback-Datensatz MCP", + "feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert", + "feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich", "feedback_records": "Feedback-Einträge", "feedback_records_refreshed": "Feedback-Einträge aktualisiert", "feedback_sources": "Feedback-Quellen", "feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}", "feedback_sources_directory_access_single": "Neue Datensätze aus dieser Quelle werden gespeichert in: {directoryNames}", "feedback_sources_settings_description": "Verbinde und verwalte alle Feedback-Quellen für diesen Workspace.", + "field_group_id": "Feldgruppen-ID", + "field_group_label": "Feldgruppenbezeichnung", + "field_id": "Feld-ID", "field_label": "Feldbezeichnung", "field_type": "Feldtyp", "formbricks_surveys": "Formbricks-Umfragen", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Bisherige Antworten importieren", "import_historical_responses_description": "Importiere jetzt vorhandene Antworten aus dieser Umfrage.", "import_rows": "{count} Zeilen importieren", + "import_via_source_name": "Import über „{sourceName}“", "importing_data": "Daten werden importiert...", "importing_historical_data": "Historische Daten werden importiert...", "invalid_enum_values": "Ungültige Werte in der Spalte, die {field} zugeordnet ist", "invalid_values_found": "Gefunden: {values} (Zeilen: {rows}) {extra}", "load_sample_csv": "Beispiel-CSV laden", "manage_directories": "Verzeichnisse verwalten", + "manage_feedback_sources": "Feedbackquellen verwalten", + "metadata": "Metadaten", + "metadata_key": "Metadatenschlüssel", + "metadata_read_only_entries": "Schreibgeschützte Metadatenwerte (keine Zeichenfolge)", + "metadata_value": "Metadatenwert", "missing_feedback_source_title": "Feedback-Quelle fehlt?", "no_feedback_record_directory_available": "Diesem Workspace ist kein Feedback-Datensatz-Verzeichnis zugewiesen. Erstelle oder weise zuerst eines zu.", "no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen", "select_a_value": "Wähle einen Wert aus...", "select_feedback_record_directory": "Verzeichnis auswählen", + "select_feedback_record_source_type": "Wählen Sie den Quelltyp aus", "select_questions": "Fragen auswählen", "select_source_type_description": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest.", "select_survey": "Umfrage auswählen", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Sende Feedback-Datensätze über die MCP-Integration.", "source_connect_formbricks_description": "Feedback aus Deinen Formbricks-Umfragen verbinden", "source_fields": "Quellfelder", + "source_id": "Quell-ID", "source_name": "Quellenname", "source_type": "Quellentyp", "source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden", @@ -3722,6 +3746,7 @@ "status_live_sync": "Live-Synchronisierung", "status_paused": "Pausiert", "status_ready": "Bereit", + "submission_id": "Einreichungs-ID", "survey_has_no_questions": "Diese Umfrage hat keine Fragen", "topics_and_subtopics": "Themen & Unterthemen", "unify_feedback": "Feedback vereinheitlichen", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Lade eine CSV-Datei hoch, um Feedback-Daten zu importieren.", "upload_csv_file": "CSV-Datei hochladen", "user_identifier": "Benutzer", - "value": "Wert" + "value": "Wert", + "value_boolean": "Wert (Boolescher Wert)", + "value_date": "Wert (Datum)", + "value_number": "Wert (Anzahl)", + "value_text": "Wert (Text)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index cd0e495a3b..a5400a4086 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -331,6 +331,7 @@ "not_authenticated": "You are not authenticated to perform this action.", "not_authorized": "Not authorized", "not_connected": "Not Connected", + "not_set": "Not set", "note": "Note", "notifications": "Notifications", "number": "Number", @@ -3618,12 +3619,15 @@ "team_settings_description": "See which teams can access this workspace." }, "unify": { + "add_feedback_record": "Add feedback record", + "add_feedback_record_description": "Create a feedback record manually.", "add_feedback_source": "Add Feedback Source", "add_source": "Add source", "allowed_values": "Allowed values: {values}", "api_ingestion": "API ingestion", "api_ingestion_manage_api_keys": "Manage API keys", "api_ingestion_settings_description": "Send feedback records using the Management API.", + "auto_generated": "Auto-generated", "change_file": "Change file", "click_load_sample_csv": "Click 'Load sample CSV' to see columns", "click_to_upload": "Click to upload", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Importing data twice will create duplicate records.", "csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.", "csv_max_records": "Maximum {max} records allowed.", + "custom_source_type": "Custom source type", + "custom_source_type_placeholder": "Enter custom source type", "default_connector_name_csv": "CSV Import", "default_connector_name_formbricks": "Formbricks Survey Connection", + "discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.", + "discard_feedback_record_changes_title": "Discard unsaved changes?", "drop_a_field_here": "Drop a field here", "drop_field_or": "Drop field or", "edit_csv_mapping": "Edit CSV mapping", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Failed to load feedback records", "feedback_date": "Current date", + "feedback_record_created_successfully": "Feedback record created successfully", + "feedback_record_details": "Feedback record details", + "feedback_record_details_description": "Review and update feedback record fields.", "feedback_record_directory": "Feedback Record Directory", "feedback_record_fields": "Feedback Record Fields", "feedback_record_mcp": "Feedback Record MCP", + "feedback_record_updated_successfully": "Feedback record updated successfully", + "feedback_record_value_required": "A value is required for the selected field type", "feedback_records": "Feedback Records", "feedback_records_refreshed": "Feedback records refreshed", "feedback_sources": "Feedback Sources", "feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}", "feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}", "feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.", + "field_group_id": "Field Group ID", + "field_group_label": "Field Group Label", + "field_id": "Field ID", "field_label": "Field Label", "field_type": "Field Type", "formbricks_surveys": "Formbricks Surveys", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Import historical responses", "import_historical_responses_description": "Import existing responses from this survey now.", "import_rows": "Import {count} rows", + "import_via_source_name": "Import via \"{sourceName}\"", "importing_data": "Importing data...", "importing_historical_data": "Importing historical data...", "invalid_enum_values": "Invalid values in column mapped to {field}", "invalid_values_found": "Found: {values} (rows: {rows}) {extra}", "load_sample_csv": "Load sample CSV", "manage_directories": "Manage directories", + "manage_feedback_sources": "Manage feedback sources", + "metadata": "Metadata", + "metadata_key": "Metadata key", + "metadata_read_only_entries": "Read-only metadata values (non-string)", + "metadata_value": "Metadata value", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_record_directory_available": "No feedback record directory assigned to this workspace. Create or assign one first.", "no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Select a survey to see its questions", "select_a_value": "Select a value...", "select_feedback_record_directory": "Select a directory", + "select_feedback_record_source_type": "Select source type", "select_questions": "Select questions", "select_source_type_description": "Select the type of feedback source you want to connect.", "select_survey": "Select Survey", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.", "source_connect_formbricks_description": "Connect feedback from your Formbricks surveys", "source_fields": "Source Fields", + "source_id": "Source ID", "source_name": "Source Name", "source_type": "Source Type", "source_type_cannot_be_changed": "Source type cannot be changed", @@ -3721,6 +3745,7 @@ "status_live_sync": "Live sync", "status_paused": "Paused", "status_ready": "Ready", + "submission_id": "Submission ID", "survey_has_no_questions": "This survey has no questions", "topics_and_subtopics": "Topics & Subtopics", "unify_feedback": "Unify Feedback", @@ -3729,7 +3754,11 @@ "upload_csv_data_description": "Upload a CSV file to import feedback data.", "upload_csv_file": "Upload CSV File", "user_identifier": "User", - "value": "Value" + "value": "Value", + "value_boolean": "Value (Boolean)", + "value_date": "Value (Date)", + "value_number": "Value (Number)", + "value_text": "Value (Text)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index ce7679a55f..c4d180231e 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -331,6 +331,7 @@ "not_authenticated": "No estás autenticado para realizar esta acción.", "not_authorized": "No autorizado", "not_connected": "No conectado", + "not_set": "No establecido", "note": "Nota", "notifications": "Notificaciones", "number": "Número", @@ -3618,12 +3619,15 @@ "team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo." }, "unify": { + "add_feedback_record": "Agregar registro de comentarios", + "add_feedback_record_description": "Cree un registro de comentarios manualmente.", "add_feedback_source": "Añadir fuente de feedback", "add_source": "Añadir fuente", "allowed_values": "Valores permitidos: {values}", "api_ingestion": "Ingesta de API", "api_ingestion_manage_api_keys": "Gestionar claves de API", "api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.", + "auto_generated": "Generado automáticamente", "change_file": "Cambiar archivo", "click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas", "click_to_upload": "Haz clic para subir", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.", "csv_inconsistent_columns": "La fila {row} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.", "csv_max_records": "Máximo de {max} registros permitidos.", + "custom_source_type": "Tipo de fuente personalizado", + "custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado", "default_connector_name_csv": "Importación CSV", "default_connector_name_formbricks": "Conexión de encuesta de Formbricks", + "discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.", + "discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?", "drop_a_field_here": "Suelta un campo aquí", "drop_field_or": "Suelta el campo o", "edit_csv_mapping": "Editar mapeo de CSV", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Error al cargar los registros de comentarios", "feedback_date": "Fecha actual", + "feedback_record_created_successfully": "Registro de comentarios creado correctamente", + "feedback_record_details": "Detalles del registro de comentarios", + "feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.", "feedback_record_directory": "Directorio de Registros de Comentarios", "feedback_record_fields": "Campos de registro de comentarios", "feedback_record_mcp": "MCP de registros de feedback", + "feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente", + "feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado", "feedback_records": "Registros de comentarios", "feedback_records_refreshed": "Registros de comentarios actualizados", "feedback_sources": "Fuentes de feedback", "feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}", "feedback_sources_directory_access_single": "Los nuevos registros de esta fuente se almacenarán en: {directoryNames}", "feedback_sources_settings_description": "Conecta y gestiona todas las fuentes de feedback para este espacio de trabajo.", + "field_group_id": "ID de grupo de campos", + "field_group_label": "Etiqueta de grupo de campos", + "field_id": "ID de campo", "field_label": "Etiqueta de campo", "field_type": "Tipo de campo", "formbricks_surveys": "Formbricks Surveys", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Importar respuestas históricas", "import_historical_responses_description": "Importa las respuestas existentes de esta encuesta ahora.", "import_rows": "Importar {count} filas", + "import_via_source_name": "Importar mediante \"{sourceName}\"", "importing_data": "Importando datos...", "importing_historical_data": "Importando datos históricos...", "invalid_enum_values": "Valores no válidos en la columna asignada a {field}", "invalid_values_found": "Encontrados: {values} (filas: {rows}) {extra}", "load_sample_csv": "Cargar CSV de muestra", "manage_directories": "Gestionar directorios", + "manage_feedback_sources": "Administrar fuentes de comentarios", + "metadata": "Metadatos", + "metadata_key": "Clave de metadatos", + "metadata_read_only_entries": "Valores de metadatos de solo lectura (no cadenas)", + "metadata_value": "Valor de metadatos", "missing_feedback_source_title": "¿Falta alguna fuente de feedback?", "no_feedback_record_directory_available": "No hay ningún directorio de registros de comentarios asignado a este espacio de trabajo. Crea o asigna uno primero.", "no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas", "select_a_value": "Selecciona un valor...", "select_feedback_record_directory": "Selecciona un directorio", + "select_feedback_record_source_type": "Seleccionar tipo de fuente", "select_questions": "Seleccionar preguntas", "select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.", "select_survey": "Seleccionar encuesta", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Envía registros de feedback a través de la integración MCP.", "source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks", "source_fields": "Campos de origen", + "source_id": "ID de fuente", "source_name": "Nombre de origen", "source_type": "Tipo de fuente", "source_type_cannot_be_changed": "El tipo de origen no se puede cambiar", @@ -3722,6 +3746,7 @@ "status_live_sync": "Sincronización en vivo", "status_paused": "Pausado", "status_ready": "Listo", + "submission_id": "ID de envío", "survey_has_no_questions": "Esta encuesta no tiene preguntas", "topics_and_subtopics": "Temas y subtemas", "unify_feedback": "Unificar feedback", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Sube un archivo CSV para importar datos de comentarios.", "upload_csv_file": "Subir archivo CSV", "user_identifier": "Usuario", - "value": "Valor" + "value": "Valor", + "value_boolean": "Valor (booleano)", + "value_date": "Valor (Fecha)", + "value_number": "Valor (Número)", + "value_text": "Valor (Texto)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 1b1edb18bf..69157281c3 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -331,6 +331,7 @@ "not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.", "not_authorized": "Non autorisé", "not_connected": "Non connecté", + "not_set": "Non défini", "note": "Remarque", "notifications": "Notifications", "number": "Numéro", @@ -3618,12 +3619,15 @@ "team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail." }, "unify": { + "add_feedback_record": "Ajouter un enregistrement de commentaires", + "add_feedback_record_description": "Créez manuellement un enregistrement de commentaires.", "add_feedback_source": "Ajouter une source de feedback", "add_source": "Ajouter une source", "allowed_values": "Valeurs autorisées : {values}", "api_ingestion": "Ingestion par API", "api_ingestion_manage_api_keys": "Gérer les clés API", "api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.", + "auto_generated": "Généré automatiquement", "change_file": "Changer de fichier", "click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes", "click_to_upload": "Clique pour charger", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Importer les données deux fois créera des enregistrements en double.", "csv_inconsistent_columns": "La ligne {row} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.", "csv_max_records": "Maximum {max} enregistrements autorisés.", + "custom_source_type": "Type de source personnalisé", + "custom_source_type_placeholder": "Entrez le type de source personnalisé", "default_connector_name_csv": "Importation CSV", "default_connector_name_formbricks": "Connexion de sondage Formbricks", + "discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.", + "discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?", "drop_a_field_here": "Déposez un champ ici", "drop_field_or": "Déposez un champ ou", "edit_csv_mapping": "Modifier le mappage CSV", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback", "feedback_date": "Date actuelle", + "feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès", + "feedback_record_details": "Détails de l'enregistrement des commentaires", + "feedback_record_details_description": "Examiner et mettre à jour les champs d’enregistrement des commentaires.", "feedback_record_directory": "Répertoire d'enregistrements de retour d'expérience", "feedback_record_fields": "Champs d'enregistrement de feedback", "feedback_record_mcp": "MCP d'enregistrement de feedback", + "feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès", + "feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné", "feedback_records": "Enregistrements de feedback", "feedback_records_refreshed": "Enregistrements de feedback actualisés", "feedback_sources": "Sources de feedback", "feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}", "feedback_sources_directory_access_single": "Les nouveaux enregistrements de cette source seront stockés dans : {directoryNames}", "feedback_sources_settings_description": "Connecte et gère toutes les sources de feedback pour cet espace de travail.", + "field_group_id": "ID de groupe de champs", + "field_group_label": "Libellé du groupe de champs", + "field_id": "Identifiant du champ", "field_label": "Libellé du champ", "field_type": "Type de champ", "formbricks_surveys": "Sondages Formbricks", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Importer les réponses historiques", "import_historical_responses_description": "Importe les réponses existantes de cette enquête maintenant.", "import_rows": "Importer {count} lignes", + "import_via_source_name": "Importer via \"{sourceName}\"", "importing_data": "Importation des données...", "importing_historical_data": "Importation des données historiques...", "invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}", "invalid_values_found": "Trouvées : {values} (lignes : {rows}) {extra}", "load_sample_csv": "Charger un exemple de CSV", "manage_directories": "Gérer les répertoires", + "manage_feedback_sources": "Gérer les sources de commentaires", + "metadata": "Métadonnées", + "metadata_key": "Clé de métadonnées", + "metadata_read_only_entries": "Valeurs de métadonnées en lecture seule (non-chaîne)", + "metadata_value": "Valeur des métadonnées", "missing_feedback_source_title": "Il manque une source de feedback ?", "no_feedback_record_directory_available": "Aucun répertoire d'enregistrements de retour d'expérience n'est assigné à cet espace de travail. Créez-en un ou assignez-en un d'abord.", "no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions", "select_a_value": "Sélectionnez une valeur...", "select_feedback_record_directory": "Sélectionner un répertoire", + "select_feedback_record_source_type": "Sélectionnez le type de source", "select_questions": "Sélectionner les questions", "select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.", "select_survey": "Sélectionner l'enquête", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Envoyer des enregistrements de feedback via l'intégration MCP.", "source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks", "source_fields": "Champs source", + "source_id": "Identifiant de la source", "source_name": "Nom de la source", "source_type": "Type de source", "source_type_cannot_be_changed": "Le type de source ne peut pas être modifié", @@ -3722,6 +3746,7 @@ "status_live_sync": "Synchronisation en direct", "status_paused": "En pause", "status_ready": "Prêt", + "submission_id": "ID de soumission", "survey_has_no_questions": "Ce sondage n'a pas de questions", "topics_and_subtopics": "Thèmes et sous-thèmes", "unify_feedback": "Unifier les retours", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Téléchargez un fichier CSV pour importer des données de feedback.", "upload_csv_file": "Télécharger un fichier CSV", "user_identifier": "Utilisateur", - "value": "Valeur" + "value": "Valeur", + "value_boolean": "Valeur (booléenne)", + "value_date": "Valeur (Date)", + "value_number": "Valeur (Nombre)", + "value_text": "Valeur (texte)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 3a238a2d46..2edb493ba5 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -331,6 +331,7 @@ "not_authenticated": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.", "not_authorized": "Nincs felhatalmazva", "not_connected": "Nincs kapcsolódva", + "not_set": "Nincs beállítva", "note": "Jegyzet", "notifications": "Értesítések", "number": "Szám", @@ -3618,12 +3619,15 @@ "team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez." }, "unify": { + "add_feedback_record": "Visszajelzés hozzáadása", + "add_feedback_record_description": "Készítsen visszajelzési rekordot manuálisan.", "add_feedback_source": "Visszajelzési forrás hozzáadása", "add_source": "Forrás hozzáadása", "allowed_values": "Engedélyezett értékek: {values}", "api_ingestion": "API betöltés", "api_ingestion_manage_api_keys": "API kulcsok kezelése", "api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.", + "auto_generated": "Automatikusan generált", "change_file": "Fájl módosítása", "click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez", "click_to_upload": "Kattintson a feltöltéshez", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Az adatok kétszeri importálása duplikált rekordokat hoz létre.", "csv_inconsistent_columns": "A(z) {row}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.", "csv_max_records": "Maximum {max} rekord engedélyezett.", + "custom_source_type": "Egyéni forrástípus", + "custom_source_type_placeholder": "Adja meg az egyéni forrástípust", "default_connector_name_csv": "CSV importálás", "default_connector_name_formbricks": "Formbricks kérdőív kapcsolat", + "discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.", + "discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?", "drop_a_field_here": "Húzz ide egy mezőt", "drop_field_or": "Húzz ide egy mezőt vagy", "edit_csv_mapping": "CSV leképezés szerkesztése", @@ -3659,15 +3667,23 @@ "enum": "felsorolás", "failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat", "feedback_date": "Aktuális dátum", + "feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva", + "feedback_record_details": "A visszajelzési rekord részletei", + "feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.", "feedback_record_directory": "Visszajelzési Rekord Könyvtár", "feedback_record_fields": "Visszajelzési rekord mezők", "feedback_record_mcp": "Visszajelzési rekord MCP", + "feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve", + "feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni", "feedback_records": "Visszajelzési rekordok", "feedback_records_refreshed": "Visszajelzési rekordok frissítve", "feedback_sources": "Visszajelzési források", "feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}", "feedback_sources_directory_access_single": "Az ebből a forrásból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}", "feedback_sources_settings_description": "Összes visszajelzési forrás csatlakoztatása és kezelése ezen munkaterület számára.", + "field_group_id": "Mezőcsoport azonosítója", + "field_group_label": "Mezőcsoport címke", + "field_id": "Mezőazonosító", "field_label": "Mező címke", "field_type": "Mező típus", "formbricks_surveys": "Formbricks kérdőívek", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Korábbi válaszok importálása", "import_historical_responses_description": "Meglévő válaszok importálása ebből a felmérésből most.", "import_rows": "{count} sor importálása", + "import_via_source_name": "Importálás a következőn keresztül: \"{sourceName}\"", "importing_data": "Adatok importálása...", "importing_historical_data": "Történeti adatok importálása...", "invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban", "invalid_values_found": "Talált értékek: {values} (sorok: {rows}) {extra}", "load_sample_csv": "Minta CSV betöltése", "manage_directories": "Könyvtárak kezelése", + "manage_feedback_sources": "Visszajelzési források kezelése", + "metadata": "Metaadatok", + "metadata_key": "Metaadatkulcs", + "metadata_read_only_entries": "Csak olvasható metaadatértékek (nem karakterlánc)", + "metadata_value": "A metaadat értéke", "missing_feedback_source_title": "Hiányzik egy visszajelzési forrás?", "no_feedback_record_directory_available": "Ehhez a munkaterülethez nem tartozik visszajelzési rekord könyvtár. Először hozzon létre vagy rendeljen hozzá egyet.", "no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez", "select_a_value": "Válassz egy értéket...", "select_feedback_record_directory": "Válasszon egy könyvtárat", + "select_feedback_record_source_type": "Válassza ki a forrás típusát", "select_questions": "Kérdések kiválasztása", "select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.", "select_survey": "Kérdőív kiválasztása", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Visszajelzési rekordok küldése az MCP integráción keresztül.", "source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből", "source_fields": "Forrásmezők", + "source_id": "Forrásazonosító", "source_name": "Forrásnév", "source_type": "Forrás típus", "source_type_cannot_be_changed": "A forrástípus nem módosítható", @@ -3722,6 +3746,7 @@ "status_live_sync": "Élő szinkronizálás", "status_paused": "Szüneteltetve", "status_ready": "Kész", + "submission_id": "Beküldés azonosítója", "survey_has_no_questions": "Ez a felmérés nem tartalmaz kérdéseket", "topics_and_subtopics": "Témák és altémák", "unify_feedback": "Visszajelzések egyesítése", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Tölts fel egy CSV fájlt a visszajelzési adatok importálásához.", "upload_csv_file": "CSV fájl feltöltése", "user_identifier": "Felhasználó", - "value": "Érték" + "value": "Érték", + "value_boolean": "Érték (logikai)", + "value_date": "Érték (dátum)", + "value_number": "Érték (szám)", + "value_text": "Érték (szöveg)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 350bba7300..eba9965d2e 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -331,6 +331,7 @@ "not_authenticated": "このアクションを実行するための認証がされていません。", "not_authorized": "権限がありません", "not_connected": "未接続", + "not_set": "未設定", "note": "メモ", "notifications": "通知", "number": "数値", @@ -3618,12 +3619,15 @@ "team_settings_description": "このワークスペースにアクセスできるチームを確認します。" }, "unify": { + "add_feedback_record": "フィードバックレコードを追加する", + "add_feedback_record_description": "フィードバック記録を手動で作成します。", "add_feedback_source": "フィードバックソースを追加", "add_source": "ソースを追加", "allowed_values": "許可される値: {values}", "api_ingestion": "API取り込み", "api_ingestion_manage_api_keys": "APIキーを管理", "api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。", + "auto_generated": "自動生成", "change_file": "ファイルを変更", "click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示", "click_to_upload": "クリックしてアップロード", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "データを2回インポートすると、重複したレコードが作成されます。", "csv_inconsistent_columns": "行 {row} の列が一致しません。すべての行は同じヘッダーを持つ必要があります。", "csv_max_records": "最大 {max} 件のレコードまで許可されています。", + "custom_source_type": "カスタムソースタイプ", + "custom_source_type_placeholder": "カスタムソースタイプを入力してください", "default_connector_name_csv": "CSVインポート", "default_connector_name_formbricks": "Formbricks フォーム接続", + "discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。", + "discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?", "drop_a_field_here": "ここにフィールドをドロップ", "drop_field_or": "フィールドをドロップまたは", "edit_csv_mapping": "CSVマッピングを編集", @@ -3659,15 +3667,23 @@ "enum": "列挙型", "failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました", "feedback_date": "現在の日付", + "feedback_record_created_successfully": "フィードバックレコードが正常に作成されました", + "feedback_record_details": "フィードバック記録の詳細", + "feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。", "feedback_record_directory": "フィードバックレコードディレクトリ", "feedback_record_fields": "フィードバックレコードフィールド", "feedback_record_mcp": "フィードバックレコードMCP", + "feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました", + "feedback_record_value_required": "選択したフィールド タイプには値が必要です", "feedback_records": "フィードバックレコード", "feedback_records_refreshed": "フィードバックレコードを更新しました", "feedback_sources": "フィードバックソース", "feedback_sources_directory_access_multiple": "これらのソースからの新しいレコードは次の場所に保存されます:{directoryNames}", "feedback_sources_directory_access_single": "このソースからの新しいレコードは次の場所に保存されます:{directoryNames}", "feedback_sources_settings_description": "このワークスペースのすべてのフィードバックソースを接続・管理します。", + "field_group_id": "フィールドグループID", + "field_group_label": "フィールドグループラベル", + "field_id": "フィールドID", "field_label": "フィールドラベル", "field_type": "フィールドタイプ", "formbricks_surveys": "Formbricks フォーム", @@ -3678,12 +3694,18 @@ "import_historical_responses": "過去の回答をインポート", "import_historical_responses_description": "このアンケートから既存の回答を今すぐインポートします。", "import_rows": "{count}行をインポート", + "import_via_source_name": "「{sourceName}」経由でインポート", "importing_data": "データをインポート中...", "importing_historical_data": "過去のデータをインポート中...", "invalid_enum_values": "{field}にマッピングされた列に無効な値があります", "invalid_values_found": "検出された値: {values}(行: {rows}){extra}", "load_sample_csv": "サンプルCSVを読み込む", "manage_directories": "ディレクトリを管理", + "manage_feedback_sources": "フィードバックソースを管理する", + "metadata": "メタデータ", + "metadata_key": "メタデータキー", + "metadata_read_only_entries": "読み取り専用メタデータ値 (非文字列)", + "metadata_value": "メタデータ値", "missing_feedback_source_title": "フィードバックソースが見つかりませんか?", "no_feedback_record_directory_available": "このワークスペースにフィードバックレコードディレクトリが割り当てられていません。まず作成または割り当てを行ってください。", "no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "フォームを選択して質問を表示", "select_a_value": "値を選択...", "select_feedback_record_directory": "ディレクトリを選択", + "select_feedback_record_source_type": "ソースタイプを選択してください", "select_questions": "質問を選択", "select_source_type_description": "接続するフィードバックソースの種類を選択してください。", "select_survey": "フォームを選択", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "MCP統合を通じてフィードバックレコードを送信します。", "source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続", "source_fields": "ソースフィールド", + "source_id": "ソースID", "source_name": "ソース名", "source_type": "ソースタイプ", "source_type_cannot_be_changed": "ソースタイプは変更できません", @@ -3722,6 +3746,7 @@ "status_live_sync": "リアルタイム同期", "status_paused": "一時停止", "status_ready": "準備完了", + "submission_id": "提出ID", "survey_has_no_questions": "このアンケートには質問がありません", "topics_and_subtopics": "トピックとサブトピック", "unify_feedback": "フィードバックを統合", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "CSVファイルをアップロードして、フィードバックデータをインポートします。", "upload_csv_file": "CSVファイルをアップロード", "user_identifier": "ユーザー", - "value": "値" + "value": "値", + "value_boolean": "値 (ブール値)", + "value_date": "値 (日付)", + "value_number": "値(数値)", + "value_text": "値 (テキスト)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index fdc5da3c6c..9c208f8518 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -331,6 +331,7 @@ "not_authenticated": "U bent niet geverifieerd om deze actie uit te voeren.", "not_authorized": "Niet geautoriseerd", "not_connected": "Niet verbonden", + "not_set": "Niet ingesteld", "note": "Opmerking", "notifications": "Meldingen", "number": "Nummer", @@ -3618,12 +3619,15 @@ "team_settings_description": "Bekijk welke teams toegang hebben tot deze workspace." }, "unify": { + "add_feedback_record": "Feedbackrecord toevoegen", + "add_feedback_record_description": "Maak handmatig een feedbackrecord.", "add_feedback_source": "Feedbackbron toevoegen", "add_source": "Bron toevoegen", "allowed_values": "Toegestane waarden: {values}", "api_ingestion": "API-inname", "api_ingestion_manage_api_keys": "API-sleutels beheren", "api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.", + "auto_generated": "Automatisch gegenereerd", "change_file": "Bestand wijzigen", "click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien", "click_to_upload": "Klik om te uploaden", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Gegevens twee keer importeren zal dubbele records aanmaken.", "csv_inconsistent_columns": "Rij {row} heeft inconsistente kolommen. Alle rijen moeten dezelfde headers hebben.", "csv_max_records": "Maximaal {max} records toegestaan.", + "custom_source_type": "Aangepast brontype", + "custom_source_type_placeholder": "Voer een aangepast brontype in", "default_connector_name_csv": "CSV import", "default_connector_name_formbricks": "Formbricks Survey verbinding", + "discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.", + "discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?", "drop_a_field_here": "Zet hier een veld neer", "drop_field_or": "Zet veld neer of", "edit_csv_mapping": "CSV-mapping bewerken", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Kan feedbackrecords niet laden", "feedback_date": "Huidige datum", + "feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt", + "feedback_record_details": "Details van feedbackrecord", + "feedback_record_details_description": "Controleer en update de feedbackrecordvelden.", "feedback_record_directory": "Feedbackrecordmap", "feedback_record_fields": "Feedbackrecordvelden", "feedback_record_mcp": "Feedbackrecord MCP", + "feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt", + "feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype", "feedback_records": "Feedbackrecords", "feedback_records_refreshed": "Feedbackrecords vernieuwd", "feedback_sources": "Feedbackbronnen", "feedback_sources_directory_access_multiple": "Nieuwe records van deze bronnen worden opgeslagen in: {directoryNames}", "feedback_sources_directory_access_single": "Nieuwe records van deze bron worden opgeslagen in: {directoryNames}", "feedback_sources_settings_description": "Verbind en beheer alle feedbackbronnen voor deze werkruimte.", + "field_group_id": "Veldgroep-ID", + "field_group_label": "Veldgroeplabel", + "field_id": "Veld-ID", "field_label": "Veldlabel", "field_type": "Veldtype", "formbricks_surveys": "Formbricks Surveys", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Historische reacties importeren", "import_historical_responses_description": "Importeer bestaande reacties van deze enquête nu.", "import_rows": "{count, plural, one {Importeer 1 rij} other {Importeer # rijen}}", + "import_via_source_name": "Importeren via \"{sourceName}\"", "importing_data": "Gegevens importeren...", "importing_historical_data": "Historische gegevens importeren...", "invalid_enum_values": "Ongeldige waarden in kolom gekoppeld aan {field}", "invalid_values_found": "Gevonden: {values} (rijen: {rows}) {extra}", "load_sample_csv": "Voorbeeld-CSV laden", "manage_directories": "Mappen beheren", + "manage_feedback_sources": "Beheer feedbackbronnen", + "metadata": "Metagegevens", + "metadata_key": "Metagegevenssleutel", + "metadata_read_only_entries": "Alleen-lezen metadatawaarden (niet-tekenreeks)", + "metadata_value": "Metagegevenswaarde", "missing_feedback_source_title": "Mis je een feedbackbron?", "no_feedback_record_directory_available": "Geen feedbackrecordmap toegewezen aan deze workspace. Maak er eerst een aan of wijs er een toe.", "no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien", "select_a_value": "Selecteer een waarde...", "select_feedback_record_directory": "Selecteer een map", + "select_feedback_record_source_type": "Selecteer brontype", "select_questions": "Selecteer vragen", "select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.", "select_survey": "Selecteer enquête", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Verstuur feedbackrecords via de MCP-integratie.", "source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes", "source_fields": "Bronvelden", + "source_id": "Bron-ID", "source_name": "Bronnaam", "source_type": "Brontype", "source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd", @@ -3722,6 +3746,7 @@ "status_live_sync": "Live synchronisatie", "status_paused": "Gepauzeerd", "status_ready": "Klaar", + "submission_id": "Inzendings-ID", "survey_has_no_questions": "Deze enquête heeft geen vragen", "topics_and_subtopics": "Onderwerpen en subonderwerpen", "unify_feedback": "Feedback verenigen", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Upload een CSV-bestand om feedbackgegevens te importeren.", "upload_csv_file": "CSV-bestand uploaden", "user_identifier": "Gebruiker", - "value": "Waarde" + "value": "Waarde", + "value_boolean": "Waarde (Booleaans)", + "value_date": "Waarde (datum)", + "value_number": "Waarde (getal)", + "value_text": "Waarde (tekst)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 63f1be0d34..cf767e9d75 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -331,6 +331,7 @@ "not_authenticated": "Você não está autenticado para realizar essa ação.", "not_authorized": "Não autorizado", "not_connected": "Desconectado", + "not_set": "Não definido", "note": "Nota", "notifications": "Notificações", "number": "Número", @@ -3618,12 +3619,15 @@ "team_settings_description": "Veja quais equipes podem acessar este workspace." }, "unify": { + "add_feedback_record": "Adicionar registro de feedback", + "add_feedback_record_description": "Crie um registro de feedback manualmente.", "add_feedback_source": "Adicionar fonte de feedback", "add_source": "Adicionar fonte", "allowed_values": "Valores permitidos: {values}", "api_ingestion": "Ingestão de API", "api_ingestion_manage_api_keys": "Gerenciar chaves de API", "api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.", + "auto_generated": "Gerado automaticamente", "change_file": "Alterar arquivo", "click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas", "click_to_upload": "Clique para fazer upload", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Importar dados duas vezes criará registros duplicados.", "csv_inconsistent_columns": "A linha {row} possui colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.", "csv_max_records": "Máximo de {max} registros permitidos.", + "custom_source_type": "Tipo de origem personalizado", + "custom_source_type_placeholder": "Insira o tipo de fonte personalizado", "default_connector_name_csv": "Importação CSV", "default_connector_name_formbricks": "Conexão de pesquisa Formbricks", + "discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.", + "discard_feedback_record_changes_title": "Descartar alterações não salvas?", "drop_a_field_here": "Solte um campo aqui", "drop_field_or": "Solte o campo ou", "edit_csv_mapping": "Editar mapeamento CSV", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Falha ao carregar registros de feedback", "feedback_date": "Data atual", + "feedback_record_created_successfully": "Registro de feedback criado com sucesso", + "feedback_record_details": "Detalhes do registro de feedback", + "feedback_record_details_description": "Revise e atualize os campos de registro de feedback.", "feedback_record_directory": "Diretório de Registros de Feedback", "feedback_record_fields": "Campos do registro de feedback", "feedback_record_mcp": "Registro de Feedback MCP", + "feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso", + "feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado", "feedback_records": "Registros de feedback", "feedback_records_refreshed": "Registros de feedback atualizados", "feedback_sources": "Fontes de Feedback", "feedback_sources_directory_access_multiple": "Novos registros dessas fontes serão armazenados em: {directoryNames}", "feedback_sources_directory_access_single": "Novos registros desta fonte serão armazenados em: {directoryNames}", "feedback_sources_settings_description": "Conecte e gerencie todas as fontes de feedback para este workspace.", + "field_group_id": "ID do grupo de campos", + "field_group_label": "Etiqueta do grupo de campos", + "field_id": "ID do campo", "field_label": "Rótulo do campo", "field_type": "Tipo de campo", "formbricks_surveys": "Pesquisas Formbricks", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Importar respostas históricas", "import_historical_responses_description": "Importe respostas existentes desta pesquisa agora.", "import_rows": "Importar {count} linhas", + "import_via_source_name": "Importar via \"{sourceName}\"", "importing_data": "Importando dados...", "importing_historical_data": "Importando dados históricos...", "invalid_enum_values": "Valores inválidos na coluna mapeada para {field}", "invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}", "load_sample_csv": "Carregar CSV de exemplo", "manage_directories": "Gerenciar diretórios", + "manage_feedback_sources": "Gerenciar fontes de feedback", + "metadata": "Metadados", + "metadata_key": "Chave de metadados", + "metadata_read_only_entries": "Valores de metadados somente leitura (sem string)", + "metadata_value": "Valor dos metadados", "missing_feedback_source_title": "Faltando alguma fonte de feedback?", "no_feedback_record_directory_available": "Nenhum diretório de registros de feedback atribuído a este workspace. Crie ou atribua um primeiro.", "no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas", "select_a_value": "Selecione um valor...", "select_feedback_record_directory": "Selecione um diretório", + "select_feedback_record_source_type": "Selecione o tipo de fonte", "select_questions": "Selecionar perguntas", "select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.", "select_survey": "Selecionar pesquisa", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Envie registros de feedback através da integração MCP.", "source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks", "source_fields": "Campos de origem", + "source_id": "ID da fonte", "source_name": "Nome da origem", "source_type": "Tipo de fonte", "source_type_cannot_be_changed": "O tipo de origem não pode ser alterado", @@ -3722,6 +3746,7 @@ "status_live_sync": "Sincronização ao vivo", "status_paused": "Pausado", "status_ready": "Pronto", + "submission_id": "ID de envio", "survey_has_no_questions": "Esta pesquisa não possui perguntas", "topics_and_subtopics": "Tópicos e subtópicos", "unify_feedback": "Unificar feedback", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Faça upload de um arquivo CSV para importar dados de feedback.", "upload_csv_file": "Fazer upload de arquivo CSV", "user_identifier": "Usuário", - "value": "Valor" + "value": "Valor", + "value_boolean": "Valor (Booleano)", + "value_date": "Valor (Data)", + "value_number": "Valor (Número)", + "value_text": "Valor (Texto)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 45e4d569e9..098e31bc1c 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -331,6 +331,7 @@ "not_authenticated": "Não está autenticado para realizar esta ação.", "not_authorized": "Não autorizado", "not_connected": "Não Conectado", + "not_set": "Não definido", "note": "Nota", "notifications": "Notificações", "number": "Número", @@ -3618,12 +3619,15 @@ "team_settings_description": "Veja quais as equipas que podem aceder a este espaço de trabalho." }, "unify": { + "add_feedback_record": "Adicionar registro de feedback", + "add_feedback_record_description": "Crie um registro de feedback manualmente.", "add_feedback_source": "Adicionar fonte de feedback", "add_source": "Adicionar fonte", "allowed_values": "Valores permitidos: {values}", "api_ingestion": "Ingestão de API", "api_ingestion_manage_api_keys": "Gerir chaves de API", "api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.", + "auto_generated": "Gerado automaticamente", "change_file": "Alterar ficheiro", "click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas", "click_to_upload": "Clique para carregar", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Importar dados duas vezes irá criar registos duplicados.", "csv_inconsistent_columns": "A linha {row} tem colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.", "csv_max_records": "Máximo de {max} registos permitidos.", + "custom_source_type": "Tipo de origem personalizado", + "custom_source_type_placeholder": "Insira o tipo de fonte personalizado", "default_connector_name_csv": "Importação CSV", "default_connector_name_formbricks": "Conexão de pesquisa Formbricks", + "discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.", + "discard_feedback_record_changes_title": "Descartar alterações não salvas?", "drop_a_field_here": "Solte um campo aqui", "drop_field_or": "Solte o campo ou", "edit_csv_mapping": "Editar mapeamento CSV", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Falha ao carregar registos de feedback", "feedback_date": "Data atual", + "feedback_record_created_successfully": "Registro de feedback criado com sucesso", + "feedback_record_details": "Detalhes do registro de feedback", + "feedback_record_details_description": "Revise e atualize os campos de registro de feedback.", "feedback_record_directory": "Diretório de Registos de Feedback", "feedback_record_fields": "Campos de registo de feedback", "feedback_record_mcp": "MCP de Registo de Feedback", + "feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso", + "feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado", "feedback_records": "Registos de feedback", "feedback_records_refreshed": "Registos de feedback atualizados", "feedback_sources": "Fontes de Feedback", "feedback_sources_directory_access_multiple": "Novos registos destas fontes serão armazenados em: {directoryNames}", "feedback_sources_directory_access_single": "Novos registos desta fonte serão armazenados em: {directoryNames}", "feedback_sources_settings_description": "Liga e gere todas as fontes de feedback para este espaço de trabalho.", + "field_group_id": "ID do grupo de campos", + "field_group_label": "Etiqueta do grupo de campos", + "field_id": "ID do campo", "field_label": "Etiqueta do campo", "field_type": "Tipo de campo", "formbricks_surveys": "Pesquisas Formbricks", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Importar respostas históricas", "import_historical_responses_description": "Importa agora as respostas existentes deste inquérito.", "import_rows": "Importar {count} linhas", + "import_via_source_name": "Importar via \"{sourceName}\"", "importing_data": "A importar dados...", "importing_historical_data": "A importar dados históricos...", "invalid_enum_values": "Valores inválidos na coluna mapeada para {field}", "invalid_values_found": "Encontrados: {values} (linhas: {rows}) {extra}", "load_sample_csv": "Carregar CSV de exemplo", "manage_directories": "Gerir diretórios", + "manage_feedback_sources": "Gerenciar fontes de feedback", + "metadata": "Metadados", + "metadata_key": "Chave de metadados", + "metadata_read_only_entries": "Valores de metadados somente leitura (sem string)", + "metadata_value": "Valor dos metadados", "missing_feedback_source_title": "Falta alguma fonte de feedback?", "no_feedback_record_directory_available": "Não há nenhum diretório de registos de feedback atribuído a este espaço de trabalho. Cria ou atribui um primeiro.", "no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas", "select_a_value": "Selecione um valor...", "select_feedback_record_directory": "Selecionar um diretório", + "select_feedback_record_source_type": "Selecione o tipo de fonte", "select_questions": "Selecionar perguntas", "select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.", "select_survey": "Selecionar inquérito", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Envia registos de feedback através da integração MCP.", "source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks", "source_fields": "Campos da fonte", + "source_id": "ID da fonte", "source_name": "Nome da fonte", "source_type": "Tipo de fonte", "source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado", @@ -3722,6 +3746,7 @@ "status_live_sync": "Sincronização em direto", "status_paused": "Em pausa", "status_ready": "Pronto", + "submission_id": "ID de envio", "survey_has_no_questions": "Este inquérito não tem perguntas", "topics_and_subtopics": "Tópicos e subtópicos", "unify_feedback": "Unificar feedback", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Carrega um ficheiro CSV para importar dados de feedback.", "upload_csv_file": "Carregar ficheiro CSV", "user_identifier": "Utilizador", - "value": "Valor" + "value": "Valor", + "value_boolean": "Valor (Booleano)", + "value_date": "Valor (Data)", + "value_number": "Valor (Número)", + "value_text": "Valor (Texto)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index b9cdf630e7..3c33ec9b03 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -331,6 +331,7 @@ "not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.", "not_authorized": "Neautorizat", "not_connected": "Neconectat", + "not_set": "Nu setat", "note": "Notă", "notifications": "Notificări", "number": "Număr", @@ -3618,12 +3619,15 @@ "team_settings_description": "Vedeți ce echipe pot accesa acest spațiu de lucru." }, "unify": { + "add_feedback_record": "Adăugați înregistrarea de feedback", + "add_feedback_record_description": "Creați manual o înregistrare de feedback.", "add_feedback_source": "Adaugă sursă de feedback", "add_source": "Adaugă sursă", "allowed_values": "Valori permise: {values}", "api_ingestion": "Ingestie API", "api_ingestion_manage_api_keys": "Gestionează cheile API", "api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.", + "auto_generated": "Generat automat", "change_file": "Schimbă fișierul", "click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele", "click_to_upload": "Apasă pentru a încărca", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Importarea datelor de două ori va crea înregistrări duplicate.", "csv_inconsistent_columns": "Rândul {row} are coloane inconsistente. Toate rândurile trebuie să aibă aceleași antete.", "csv_max_records": "Sunt permise maximum {max} înregistrări.", + "custom_source_type": "Tip sursă personalizat", + "custom_source_type_placeholder": "Introduceți tipul de sursă personalizat", "default_connector_name_csv": "Import CSV", "default_connector_name_formbricks": "Conexiune chestionar Formbricks", + "discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.", + "discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?", "drop_a_field_here": "Trage un câmp aici", "drop_field_or": "Trage câmpul sau", "edit_csv_mapping": "Editează maparea CSV", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback", "feedback_date": "Data curentă", + "feedback_record_created_successfully": "Înregistrare de feedback creată cu succes", + "feedback_record_details": "Detaliile înregistrării feedback-ului", + "feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.", "feedback_record_directory": "Director de înregistrări feedback", "feedback_record_fields": "Câmpuri înregistrare feedback", "feedback_record_mcp": "MCP Înregistrări Feedback", + "feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes", + "feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat", "feedback_records": "Înregistrări de feedback", "feedback_records_refreshed": "Înregistrările de feedback au fost actualizate", "feedback_sources": "Surse de feedback", "feedback_sources_directory_access_multiple": "Înregistrările noi din aceste surse vor fi stocate în: {directoryNames}", "feedback_sources_directory_access_single": "Înregistrările noi din această sursă vor fi stocate în: {directoryNames}", "feedback_sources_settings_description": "Conectează și gestionează toate sursele de feedback pentru acest spațiu de lucru.", + "field_group_id": "ID grup de câmpuri", + "field_group_label": "Eticheta grupului de câmpuri", + "field_id": "ID-ul câmpului", "field_label": "Etichetă câmp", "field_type": "Tip câmp", "formbricks_surveys": "Chestionare Formbricks", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Importă răspunsuri istorice", "import_historical_responses_description": "Importă acum răspunsurile existente din acest sondaj.", "import_rows": "Importă {count, plural, one {# rând} few {# rânduri} other {# de rânduri}}", + "import_via_source_name": "Import prin „{sourceName}”", "importing_data": "Se importă datele...", "importing_historical_data": "Se importă datele istorice...", "invalid_enum_values": "Valori invalide în coloana mapată la {field}", "invalid_values_found": "Găsite: {values} (rânduri: {rows}) {extra}", "load_sample_csv": "Încarcă un CSV de exemplu", "manage_directories": "Gestionează directoarele", + "manage_feedback_sources": "Gestionați sursele de feedback", + "metadata": "Metadate", + "metadata_key": "Cheia de metadate", + "metadata_read_only_entries": "Valori de metadate numai pentru citire (fără șir)", + "metadata_value": "Valoarea metadatelor", "missing_feedback_source_title": "Lipsește o sursă de feedback?", "no_feedback_record_directory_available": "Niciun director de înregistrări feedback atribuit acestui spațiu de lucru. Creează sau atribuie unul mai întâi.", "no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările", "select_a_value": "Selectează o valoare...", "select_feedback_record_directory": "Selectează un director", + "select_feedback_record_source_type": "Selectați tipul sursei", "select_questions": "Selectează întrebări", "select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.", "select_survey": "Selectează chestionar", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Trimite înregistrări de feedback prin integrarea MCP.", "source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks", "source_fields": "Câmpuri sursă", + "source_id": "ID sursă", "source_name": "Nume sursă", "source_type": "Tip sursă", "source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat", @@ -3722,6 +3746,7 @@ "status_live_sync": "Sincronizare în timp real", "status_paused": "Pauzat", "status_ready": "Gata", + "submission_id": "ID-ul trimiterii", "survey_has_no_questions": "Acest sondaj nu are întrebări", "topics_and_subtopics": "Subiecte și subiecte secundare", "unify_feedback": "Unify Feedback", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Încarcă un fișier CSV pentru a importa date de feedback.", "upload_csv_file": "Încarcă fișier CSV", "user_identifier": "Utilizator", - "value": "Valoare" + "value": "Valoare", + "value_boolean": "Valoare (booleană)", + "value_date": "Valoare (data)", + "value_number": "Valoare (număr)", + "value_text": "Valoare (Text)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 063027ab10..fa48cdce67 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -331,6 +331,7 @@ "not_authenticated": "У вас нет прав для выполнения этого действия.", "not_authorized": "Нет доступа", "not_connected": "Нет подключения", + "not_set": "Не установлено", "note": "Примечание", "notifications": "Уведомления", "number": "Номер", @@ -3618,12 +3619,15 @@ "team_settings_description": "Посмотрите, какие команды имеют доступ к этому рабочему пространству." }, "unify": { + "add_feedback_record": "Добавить запись отзыва", + "add_feedback_record_description": "Создайте запись обратной связи вручную.", "add_feedback_source": "Добавить источник отзывов", "add_source": "Добавить источник", "allowed_values": "Допустимые значения: {values}", "api_ingestion": "Импорт через API", "api_ingestion_manage_api_keys": "Управление API-ключами", "api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.", + "auto_generated": "Автоматически генерируется", "change_file": "Изменить файл", "click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы", "click_to_upload": "Кликните для загрузки", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Импорт уже загруженных данных может создать дубликаты записей.", "csv_inconsistent_columns": "В строке {row} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.", "csv_max_records": "Допустимо не более {max} записей.", + "custom_source_type": "Пользовательский тип источника", + "custom_source_type_placeholder": "Введите собственный тип источника", "default_connector_name_csv": "Импорт CSV", "default_connector_name_formbricks": "Подключение опроса Formbricks", + "discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.", + "discard_feedback_record_changes_title": "Отменить несохраненные изменения?", "drop_a_field_here": "Перетащи сюда поле", "drop_field_or": "Перетащи поле или", "edit_csv_mapping": "Редактировать сопоставление CSV", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Не удалось загрузить отзывы", "feedback_date": "Текущая дата", + "feedback_record_created_successfully": "Запись отзыва успешно создана", + "feedback_record_details": "Детали записи обратной связи", + "feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.", "feedback_record_directory": "Каталог записей обратной связи", "feedback_record_fields": "Поля записи отзыва", "feedback_record_mcp": "MCP для записей обратной связи", + "feedback_record_updated_successfully": "Запись отзыва успешно обновлена.", + "feedback_record_value_required": "Требуется значение для выбранного типа поля.", "feedback_records": "Записи отзывов", "feedback_records_refreshed": "Записи отзывов обновлены", "feedback_sources": "Источники обратной связи", "feedback_sources_directory_access_multiple": "Новые записи из этих источников будут сохранены в: {directoryNames}", "feedback_sources_directory_access_single": "Новые записи из этого источника будут сохранены в: {directoryNames}", "feedback_sources_settings_description": "Подключайте источники обратной связи и управляйте ими для этого рабочего пространства.", + "field_group_id": "Идентификатор группы полей", + "field_group_label": "Метка группы полей", + "field_id": "Идентификатор поля", "field_label": "Метка поля", "field_type": "Тип поля", "formbricks_surveys": "Formbricks Surveys", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Импортировать предыдущие ответы", "import_historical_responses_description": "Импортируйте существующие ответы из этого опроса прямо сейчас.", "import_rows": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}", + "import_via_source_name": "Импорт через «{sourceName}»", "importing_data": "Импорт данных...", "importing_historical_data": "Импорт исторических данных...", "invalid_enum_values": "Недопустимые значения в столбце, сопоставленном с {field}", "invalid_values_found": "Найдено: {values} (строки: {rows}) {extra}", "load_sample_csv": "Загрузить пример CSV", "manage_directories": "Управление директориями", + "manage_feedback_sources": "Управление источниками обратной связи", + "metadata": "Метаданные", + "metadata_key": "Ключ метаданных", + "metadata_read_only_entries": "Значения метаданных только для чтения (нестроковые)", + "metadata_value": "Значение метаданных", "missing_feedback_source_title": "Не нашли нужный источник обратной связи?", "no_feedback_record_directory_available": "К этому рабочему пространству не назначен каталог записей обратной связи. Сначала создайте или назначьте каталог.", "no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы", "select_a_value": "Выберите значение...", "select_feedback_record_directory": "Выберите каталог", + "select_feedback_record_source_type": "Выберите тип источника", "select_questions": "Выберите вопросы", "select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.", "select_survey": "Выбрать опрос", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Отправляйте записи обратной связи через интеграцию MCP.", "source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks", "source_fields": "Поля источника", + "source_id": "Идентификатор источника", "source_name": "Имя источника", "source_type": "Тип источника", "source_type_cannot_be_changed": "Тип источника нельзя изменить", @@ -3722,6 +3746,7 @@ "status_live_sync": "Синхронизация в реальном времени", "status_paused": "Приостановлен", "status_ready": "Готово", + "submission_id": "Идентификатор отправки", "survey_has_no_questions": "В этом опросе нет вопросов", "topics_and_subtopics": "Темы и подтемы", "unify_feedback": "Обратная связь Unify", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Загрузи CSV-файл, чтобы импортировать данные отзывов.", "upload_csv_file": "Загрузить CSV-файл", "user_identifier": "Пользователь", - "value": "Значение" + "value": "Значение", + "value_boolean": "Значение (логическое)", + "value_date": "Значение (Дата)", + "value_number": "Значение (число)", + "value_text": "Значение (текст)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 7cc0ad5a52..7b7fbd30c8 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -331,6 +331,7 @@ "not_authenticated": "Du är inte autentiserad för att utföra denna åtgärd.", "not_authorized": "Ej behörig", "not_connected": "Ej ansluten", + "not_set": "Inte inställt", "note": "Anteckning", "notifications": "Aviseringar", "number": "Nummer", @@ -3618,12 +3619,15 @@ "team_settings_description": "Se vilka team som har tillgång till denna arbetsyta." }, "unify": { + "add_feedback_record": "Lägg till feedbackpost", + "add_feedback_record_description": "Skapa en feedbackpost manuellt.", "add_feedback_source": "Lägg till feedbackkälla", "add_source": "Lägg till källa", "allowed_values": "Tillåtna värden: {values}", "api_ingestion": "API ingestion", "api_ingestion_manage_api_keys": "Manage API keys", "api_ingestion_settings_description": "Send feedback records using the Management API.", + "auto_generated": "Automatiskt genererad", "change_file": "Byt fil", "click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner", "click_to_upload": "Klicka för att ladda upp", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Om du importerar data två gånger kommer det att skapa dubbletter.", "csv_inconsistent_columns": "Rad {row} har inkonsekventa kolumner. Alla rader måste ha samma rubriker.", "csv_max_records": "Maximalt {max} poster tillåtna.", + "custom_source_type": "Anpassad källtyp", + "custom_source_type_placeholder": "Ange anpassad källtyp", "default_connector_name_csv": "CSV-import", "default_connector_name_formbricks": "Formbricks Survey-anslutning", + "discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.", + "discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?", "drop_a_field_here": "Släpp ett fält här", "drop_field_or": "Släpp fält eller", "edit_csv_mapping": "Redigera CSV-mappning", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter", "feedback_date": "Aktuellt datum", + "feedback_record_created_successfully": "Feedbackposten har skapats", + "feedback_record_details": "Feedbackpostdetaljer", + "feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.", "feedback_record_directory": "Katalog för feedbackposter", "feedback_record_fields": "Fält för feedbackpost", "feedback_record_mcp": "Feedback Record MCP", + "feedback_record_updated_successfully": "Feedbackposten har uppdaterats", + "feedback_record_value_required": "Ett värde krävs för den valda fälttypen", "feedback_records": "Feedbackposter", "feedback_records_refreshed": "Feedbackposter har uppdaterats", "feedback_sources": "Feedback Sources", "feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}", "feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}", "feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.", + "field_group_id": "Fältgrupp-ID", + "field_group_label": "Fältgruppsetikett", + "field_id": "Fält-ID", "field_label": "Fältetikett", "field_type": "Fälttyp", "formbricks_surveys": "Formbricks Surveys", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Import historical responses", "import_historical_responses_description": "Import existing responses from this survey now.", "import_rows": "Importera {count} rader", + "import_via_source_name": "Importera via \"{sourceName}\"", "importing_data": "Importerar data...", "importing_historical_data": "Importerar historisk data...", "invalid_enum_values": "Ogiltiga värden i kolumnen som är kopplad till {field}", "invalid_values_found": "Hittade: {values} (rader: {rows}) {extra}", "load_sample_csv": "Ladda exempel-CSV", "manage_directories": "Manage directories", + "manage_feedback_sources": "Hantera feedbackkällor", + "metadata": "Metadata", + "metadata_key": "Metadatanyckel", + "metadata_read_only_entries": "Skrivskyddade metadatavärden (icke-sträng)", + "metadata_value": "Metadatavärde", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_record_directory_available": "Ingen katalog för feedbackposter tilldelad till den här arbetsytan. Skapa eller tilldela en först.", "no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor", "select_a_value": "Välj ett värde...", "select_feedback_record_directory": "Välj en katalog", + "select_feedback_record_source_type": "Välj källtyp", "select_questions": "Välj frågor", "select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.", "select_survey": "Välj enkät", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.", "source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter", "source_fields": "Källfält", + "source_id": "Käll-ID", "source_name": "Källnamn", "source_type": "Källtyp", "source_type_cannot_be_changed": "Källtyp kan inte ändras", @@ -3722,6 +3746,7 @@ "status_live_sync": "Live sync", "status_paused": "Pausad", "status_ready": "Ready", + "submission_id": "Inlämnings-ID", "survey_has_no_questions": "Den här enkäten har inga frågor", "topics_and_subtopics": "Ämnen och delämnen", "unify_feedback": "Samla feedback", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Ladda upp en CSV-fil för att importera feedbackdata.", "upload_csv_file": "Ladda upp CSV-fil", "user_identifier": "Användare", - "value": "Värde" + "value": "Värde", + "value_boolean": "Värde (booleskt)", + "value_date": "Värde (datum)", + "value_number": "Värde (antal)", + "value_text": "Värde (text)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 8a1d340fcc..88a8a992a9 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -331,6 +331,7 @@ "not_authenticated": "Bu işlemi gerçekleştirmek için yetkiniz yok.", "not_authorized": "Yetkisiz", "not_connected": "Bağlı Değil", + "not_set": "Ayarlanmadı", "note": "Not", "notifications": "Bildirimler", "number": "Sayı", @@ -3618,12 +3619,15 @@ "team_settings_description": "Bu çalışma alanına hangi takımların erişebildiğini görün." }, "unify": { + "add_feedback_record": "Geri bildirim kaydı ekle", + "add_feedback_record_description": "Manuel olarak bir geri bildirim kaydı oluşturun.", "add_feedback_source": "Geri Bildirim Kaynağı Ekle", "add_source": "Kaynak ekle", "allowed_values": "İzin verilen değerler: {values}", "api_ingestion": "API ingestion", "api_ingestion_manage_api_keys": "Manage API keys", "api_ingestion_settings_description": "Send feedback records using the Management API.", + "auto_generated": "Otomatik olarak oluşturuldu", "change_file": "Dosyayı değiştir", "click_load_sample_csv": "Sütunları görmek için 'Örnek CSV yükle'ye tıkla", "click_to_upload": "Yüklemek için tıkla", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "Verileri iki kez içe aktarmak yinelenen kayıtlar oluşturacaktır.", "csv_inconsistent_columns": "Satır {row} tutarsız sütunlara sahip. Tüm satırlar aynı başlıklara sahip olmalıdır.", "csv_max_records": "Maksimum {max} kayda izin verilir.", + "custom_source_type": "Özel kaynak türü", + "custom_source_type_placeholder": "Özel kaynak türünü girin", "default_connector_name_csv": "CSV İçe Aktarma", "default_connector_name_formbricks": "Formbricks Anket Bağlantısı", + "discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.", + "discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?", "drop_a_field_here": "Buraya bir alan bırakın", "drop_field_or": "Alan bırakın veya", "edit_csv_mapping": "CSV eşlemesini düzenle", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi", "feedback_date": "Geçerli tarih", + "feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu", + "feedback_record_details": "Geri bildirim kaydı ayrıntıları", + "feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.", "feedback_record_directory": "Geri Bildirim Kayıt Dizini", "feedback_record_fields": "Geri Bildirim Kayıt Alanları", "feedback_record_mcp": "Feedback Record MCP", + "feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi", + "feedback_record_value_required": "Seçilen alan türü için bir değer gerekli", "feedback_records": "Geri Bildirim Kayıtları", "feedback_records_refreshed": "Geri bildirim kayıtları yenilendi", "feedback_sources": "Feedback Sources", "feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}", "feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}", "feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.", + "field_group_id": "Alan Grubu Kimliği", + "field_group_label": "Alan Grubu Etiketi", + "field_id": "Alan Kimliği", "field_label": "Alan Etiketi", "field_type": "Alan Türü", "formbricks_surveys": "Formbricks Anketleri", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Import historical responses", "import_historical_responses_description": "Import existing responses from this survey now.", "import_rows": "{count} satır içe aktar", + "import_via_source_name": "\"{sourceName}\" yoluyla içe aktar", "importing_data": "Veri içe aktarılıyor...", "importing_historical_data": "Geçmiş veriler içe aktarılıyor...", "invalid_enum_values": "{field} alanına eşlenen sütunda geçersiz değerler", "invalid_values_found": "Bulunan: {values} (satırlar: {rows}) {extra}", "load_sample_csv": "Örnek CSV yükle", "manage_directories": "Manage directories", + "manage_feedback_sources": "Geri bildirim kaynaklarını yönetin", + "metadata": "Meta veriler", + "metadata_key": "Meta veri anahtarı", + "metadata_read_only_entries": "Salt okunur meta veri değerleri (dize dışı)", + "metadata_value": "Meta veri değeri", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_record_directory_available": "Bu çalışma alanına atanmış bir geri bildirim kayıt dizini yok. Önce bir tane oluştur veya ata.", "no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "Sorularını görmek için bir anket seç", "select_a_value": "Bir değer seç...", "select_feedback_record_directory": "Bir dizin seç", + "select_feedback_record_source_type": "Kaynak türünü seçin", "select_questions": "Soru seç", "select_source_type_description": "Bağlamak istediğin geri bildirim kaynağının türünü seç.", "select_survey": "Anket Seç", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.", "source_connect_formbricks_description": "Formbricks anketlerinizdeki geri bildirimleri bağlayın", "source_fields": "Kaynak Alanları", + "source_id": "Kaynak kimliği", "source_name": "Kaynak Adı", "source_type": "Kaynak Türü", "source_type_cannot_be_changed": "Kaynak türü değiştirilemez", @@ -3722,6 +3746,7 @@ "status_live_sync": "Live sync", "status_paused": "Duraklatıldı", "status_ready": "Ready", + "submission_id": "Gönderim Kimliği", "survey_has_no_questions": "Bu ankette soru yok", "topics_and_subtopics": "Konular ve alt konular", "unify_feedback": "Geri Bildirimleri Birleştir", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "Geri bildirim verilerini içe aktarmak için bir CSV dosyası yükle.", "upload_csv_file": "CSV Dosyası Yükle", "user_identifier": "Kullanıcı", - "value": "Değer" + "value": "Değer", + "value_boolean": "Değer (Boolean)", + "value_date": "Değer (Tarih)", + "value_number": "Değer (Sayı)", + "value_text": "Değer (Metin)" }, "xm-templates": { "ces": "CES", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index d97840b3d6..986f76705d 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -331,6 +331,7 @@ "not_authenticated": "您 未 认证 以 执行 该 操作。", "not_authorized": "未授权", "not_connected": "未连接", + "not_set": "未设置", "note": "注释", "notifications": "通知", "number": "数字", @@ -3618,12 +3619,15 @@ "team_settings_description": "查看哪些团队可以访问此工作区。" }, "unify": { + "add_feedback_record": "添加反馈记录", + "add_feedback_record_description": "手动创建反馈记录。", "add_feedback_source": "添加反馈来源", "add_source": "添加来源", "allowed_values": "允许的值:{values}", "api_ingestion": "API ingestion", "api_ingestion_manage_api_keys": "Manage API keys", "api_ingestion_settings_description": "Send feedback records using the Management API.", + "auto_generated": "自动生成", "change_file": "更换文件", "click_load_sample_csv": "点击“加载示例 CSV”查看列", "click_to_upload": "点击上传", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "重复导入数据会产生重复记录。", "csv_inconsistent_columns": "第 {row} 行的列数不一致。所有行必须有相同的表头。", "csv_max_records": "最多允许 {max} 条记录。", + "custom_source_type": "自定义源类型", + "custom_source_type_placeholder": "输入自定义来源类型", "default_connector_name_csv": "CSV 导入", "default_connector_name_formbricks": "Formbricks 调查连接", + "discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。", + "discard_feedback_record_changes_title": "放弃未保存的更改?", "drop_a_field_here": "将字段拖到这里", "drop_field_or": "拖放字段或", "edit_csv_mapping": "编辑 CSV 映射", @@ -3659,15 +3667,23 @@ "enum": "枚举", "failed_to_load_feedback_records": "加载反馈记录失败", "feedback_date": "当前日期", + "feedback_record_created_successfully": "反馈记录创建成功", + "feedback_record_details": "反馈记录详情", + "feedback_record_details_description": "查看并更新反馈记录字段。", "feedback_record_directory": "反馈记录目录", "feedback_record_fields": "反馈记录字段", "feedback_record_mcp": "Feedback Record MCP", + "feedback_record_updated_successfully": "反馈记录更新成功", + "feedback_record_value_required": "所选字段类型需要一个值", "feedback_records": "反馈记录", "feedback_records_refreshed": "反馈记录已刷新", "feedback_sources": "Feedback Sources", "feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}", "feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}", "feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.", + "field_group_id": "字段组 ID", + "field_group_label": "字段组标签", + "field_id": "字段ID", "field_label": "字段标签", "field_type": "字段类型", "formbricks_surveys": "Formbricks Surveys", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Import historical responses", "import_historical_responses_description": "Import existing responses from this survey now.", "import_rows": "导入{count}行数据", + "import_via_source_name": "通过“{sourceName}”导入", "importing_data": "正在导入数据…", "importing_historical_data": "正在导入历史数据…", "invalid_enum_values": "映射到 {field} 的列中存在无效值", "invalid_values_found": "发现:{values}(行:{rows}){extra}", "load_sample_csv": "加载示例 CSV", "manage_directories": "Manage directories", + "manage_feedback_sources": "管理反馈来源", + "metadata": "元数据", + "metadata_key": "元数据键", + "metadata_read_only_entries": "只读元数据值(非字符串)", + "metadata_value": "元数据值", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_record_directory_available": "此工作区未分配反馈记录目录。请先创建或分配一个。", "no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "请选择一个调查以查看其问题", "select_a_value": "选择一个值...", "select_feedback_record_directory": "选择目录", + "select_feedback_record_source_type": "选择来源类型", "select_questions": "选择问题", "select_source_type_description": "请选择你想要连接的反馈来源类型。", "select_survey": "选择调查", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.", "source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈", "source_fields": "来源字段", + "source_id": "源ID", "source_name": "来源名称", "source_type": "来源类型", "source_type_cannot_be_changed": "来源类型无法更改", @@ -3722,6 +3746,7 @@ "status_live_sync": "Live sync", "status_paused": "已暂停", "status_ready": "Ready", + "submission_id": "提交ID", "survey_has_no_questions": "该调查没有任何问题", "topics_and_subtopics": "主题和子主题", "unify_feedback": "统一反馈", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "上传 CSV 文件以导入反馈数据。", "upload_csv_file": "上传 CSV 文件", "user_identifier": "用户", - "value": "值" + "value": "值", + "value_boolean": "值(布尔值)", + "value_date": "值(日期)", + "value_number": "值(数量)", + "value_text": "值(文本)" }, "xm-templates": { "ces": "客户努力评分", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index e03e08d788..82d29e935b 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -331,6 +331,7 @@ "not_authenticated": "您未經授權執行此操作。", "not_authorized": "未授權", "not_connected": "未連線", + "not_set": "未設定", "note": "筆記", "notifications": "通知", "number": "數字", @@ -3618,12 +3619,15 @@ "team_settings_description": "查看哪些團隊可以存取此工作區。" }, "unify": { + "add_feedback_record": "新增回饋記錄", + "add_feedback_record_description": "手動建立回饋記錄。", "add_feedback_source": "新增回饋來源", "add_source": "新增來源", "allowed_values": "允許的值:{values}", "api_ingestion": "API ingestion", "api_ingestion_manage_api_keys": "Manage API keys", "api_ingestion_settings_description": "Send feedback records using the Management API.", + "auto_generated": "自動生成", "change_file": "更換檔案", "click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位", "click_to_upload": "點擊以上傳", @@ -3648,8 +3652,12 @@ "csv_import_duplicate_warning": "匯入已經匯入過的資料,可能會產生重複紀錄。", "csv_inconsistent_columns": "第 {row} 列的欄位數不一致。所有列必須有相同的標題。", "csv_max_records": "最多允許 {max} 筆紀錄。", + "custom_source_type": "自訂來源類型", + "custom_source_type_placeholder": "輸入自訂來源類型", "default_connector_name_csv": "CSV 匯入", "default_connector_name_formbricks": "Formbricks 問卷連線", + "discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。", + "discard_feedback_record_changes_title": "放棄未儲存的變更?", "drop_a_field_here": "請將欄位拖曳到這裡", "drop_field_or": "拖曳欄位或", "edit_csv_mapping": "編輯 CSV 對應", @@ -3659,15 +3667,23 @@ "enum": "enum", "failed_to_load_feedback_records": "載入回饋紀錄失敗", "feedback_date": "目前日期", + "feedback_record_created_successfully": "回饋記錄創建成功", + "feedback_record_details": "反饋記錄詳情", + "feedback_record_details_description": "查看並更新回饋記錄欄位。", "feedback_record_directory": "意見回饋記錄目錄", "feedback_record_fields": "回饋紀錄欄位", "feedback_record_mcp": "Feedback Record MCP", + "feedback_record_updated_successfully": "回饋記錄更新成功", + "feedback_record_value_required": "所選欄位類型需要一個值", "feedback_records": "回饋紀錄", "feedback_records_refreshed": "回饋紀錄已更新", "feedback_sources": "Feedback Sources", "feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}", "feedback_sources_directory_access_single": "New records from this source will be stored in: {directoryNames}", "feedback_sources_settings_description": "Connect and manage all feedback sources for this workspace.", + "field_group_id": "字段組 ID", + "field_group_label": "字段組標籤", + "field_id": "欄位ID", "field_label": "欄位標籤", "field_type": "欄位類型", "formbricks_surveys": "Formbricks 問卷", @@ -3678,12 +3694,18 @@ "import_historical_responses": "Import historical responses", "import_historical_responses_description": "Import existing responses from this survey now.", "import_rows": "匯入 {count} 筆資料", + "import_via_source_name": "透過“{sourceName}”導入", "importing_data": "正在匯入資料…", "importing_historical_data": "正在匯入歷史資料…", "invalid_enum_values": "對應到 {field} 欄位的值無效", "invalid_values_found": "發現:{values}(列:{rows}){extra}", "load_sample_csv": "載入範例 CSV", "manage_directories": "Manage directories", + "manage_feedback_sources": "管理回饋來源", + "metadata": "元數據", + "metadata_key": "元資料鍵", + "metadata_read_only_entries": "唯讀元資料值(非字串)", + "metadata_value": "元資料值", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_record_directory_available": "此工作區尚未指派意見回饋記錄目錄。請先建立或指派一個目錄。", "no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。", @@ -3700,6 +3722,7 @@ "select_a_survey_to_see_questions": "請選擇問卷以查看其問題", "select_a_value": "請選擇一個值...", "select_feedback_record_directory": "選擇目錄", + "select_feedback_record_source_type": "選擇來源類型", "select_questions": "選擇問題", "select_source_type_description": "請選擇你想要連接的回饋來源類型。", "select_survey": "選擇問卷", @@ -3714,6 +3737,7 @@ "source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.", "source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋", "source_fields": "來源欄位", + "source_id": "來源ID", "source_name": "來源名稱", "source_type": "來源類型", "source_type_cannot_be_changed": "來源類型無法變更", @@ -3722,6 +3746,7 @@ "status_live_sync": "Live sync", "status_paused": "已暫停", "status_ready": "Ready", + "submission_id": "提交ID", "survey_has_no_questions": "此問卷沒有任何題目", "topics_and_subtopics": "主題與子主題", "unify_feedback": "整合回饋", @@ -3730,7 +3755,11 @@ "upload_csv_data_description": "上傳 CSV 檔案以匯入回饋資料。", "upload_csv_file": "上傳 CSV 檔案", "user_identifier": "使用者", - "value": "值" + "value": "值", + "value_boolean": "值(布林值)", + "value_date": "值(日期)", + "value_number": "值(數量)", + "value_text": "值(文字)" }, "xm-templates": { "ces": "CES", From 5f30618de51a1c76c7a2f047f4f313593975ceb3 Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Tue, 28 Apr 2026 15:53:22 +0530 Subject: [PATCH 4/6] refactor: restructure feedback records with security and type safety fixes - Move TSX components into components/ subfolder - Extract types/schemas into lib/types.ts and utils into lib/utils.ts - Remove `as unknown as` double-casts in actions.ts with explicit field mapping - Fix IDOR: use generic "not found" error instead of AuthorizationError for directory mismatch, parallelize auth + directory checks in retrieve/update - Replace `as never` casts with proper isPresetSourceType type guard and explicit updatePayload typing - Remove unused directoryName interpolation param from showing_count_loaded - Deduplicate formatSourceType across table and drawer Co-Authored-By: Claude Opus 4.6 --- .../unify/feedback-records/actions.ts | 75 ++++-- .../feedback-record-form-drawer.tsx | 236 +++--------------- .../feedback-records-page-client.tsx | 2 +- .../feedback-records-table.tsx | 32 +-- .../unify/feedback-records/lib/types.ts | 57 +++++ .../unify/feedback-records/lib/utils.ts | 143 +++++++++++ .../unify/feedback-records/page.tsx | 2 +- apps/web/i18n.lock | 36 ++- apps/web/locales/de-DE.json | 1 - apps/web/locales/es-ES.json | 1 - apps/web/locales/fr-FR.json | 1 - apps/web/locales/hu-HU.json | 1 - apps/web/locales/ja-JP.json | 1 - apps/web/locales/nl-NL.json | 1 - apps/web/locales/pt-BR.json | 1 - apps/web/locales/pt-PT.json | 1 - apps/web/locales/ro-RO.json | 1 - apps/web/locales/ru-RU.json | 1 - apps/web/locales/sv-SE.json | 1 - apps/web/locales/tr-TR.json | 1 - apps/web/locales/zh-Hans-CN.json | 1 - apps/web/locales/zh-Hant-TW.json | 1 - 22 files changed, 326 insertions(+), 271 deletions(-) rename apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/{ => components}/feedback-record-form-drawer.tsx (83%) rename apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/{ => components}/feedback-records-page-client.tsx (93%) rename apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/{ => components}/feedback-records-table.tsx (93%) create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/types.ts create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.ts diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts index 47f97f6b18..40f4e8c1f0 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/actions.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { AuthorizationError } from "@formbricks/types/errors"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; @@ -107,9 +106,10 @@ const getWorkspaceDirectoryIds = async (workspaceId: string): Promise directory.id)); }; -const assertWorkspaceDirectoryAccess = (directoryIds: Set, tenantId: string): void => { +const assertRecordBelongsToWorkspace = (directoryIds: Set, tenantId: string): void => { if (!directoryIds.has(tenantId)) { - throw new AuthorizationError("Invalid feedback record directory for this workspace"); + // Throw a generic error indistinguishable from "not found" to prevent IDOR + throw new Error("Feedback record not found"); } }; @@ -123,15 +123,17 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; }) => { - await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read"); + const [, workspaceDirectoryIds] = await Promise.all([ + ensureAccess(ctx.user.id, parsedInput.workspaceId, "read"), + getWorkspaceDirectoryIds(parsedInput.workspaceId), + ]); const recordResult = await retrieveFeedbackRecord(parsedInput.recordId); if (!recordResult.data || recordResult.error) { - throw new Error(recordResult.error?.message || "Failed to retrieve feedback record"); + throw new Error("Feedback record not found"); } - const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId); - assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id); + assertRecordBelongsToWorkspace(workspaceDirectoryIds, recordResult.data.tenant_id); return recordResult.data; } @@ -150,11 +152,31 @@ export const createFeedbackRecordAction = authenticatedActionClient await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"); const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId); - assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id); + assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id); - const createResult = await createFeedbackRecord( - parsedInput.recordInput as unknown as FeedbackRecordCreateParams - ); + const { recordInput } = parsedInput; + const createParams: FeedbackRecordCreateParams = { + submission_id: recordInput.submission_id, + tenant_id: recordInput.tenant_id, + source_type: recordInput.source_type, + field_id: recordInput.field_id, + field_type: recordInput.field_type, + collected_at: recordInput.collected_at, + source_id: recordInput.source_id, + source_name: recordInput.source_name, + field_label: recordInput.field_label, + field_group_id: recordInput.field_group_id, + field_group_label: recordInput.field_group_label, + value_text: recordInput.value_text, + value_number: recordInput.value_number, + value_boolean: recordInput.value_boolean, + value_date: recordInput.value_date, + metadata: recordInput.metadata, + language: recordInput.language, + user_identifier: recordInput.user_identifier, + }; + + const createResult = await createFeedbackRecord(createParams); if (!createResult.data || createResult.error) { throw new Error(createResult.error?.message || "Failed to create feedback record"); } @@ -173,21 +195,36 @@ export const updateFeedbackRecordAction = authenticatedActionClient ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; }) => { - await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"); + const [, workspaceDirectoryIds] = await Promise.all([ + ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"), + getWorkspaceDirectoryIds(parsedInput.workspaceId), + ]); const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId); if (!currentRecordResult.data || currentRecordResult.error) { - throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record"); + throw new Error("Feedback record not found"); } - const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId); - assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id); + assertRecordBelongsToWorkspace(workspaceDirectoryIds, currentRecordResult.data.tenant_id); - const updatePayload = Object.fromEntries( - Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined) - ) as unknown as FeedbackRecordUpdateParams; + const { updateInput } = parsedInput; + const updateParams: FeedbackRecordUpdateParams = { + ...(updateInput.value_text !== undefined && { value_text: updateInput.value_text ?? undefined }), + ...(updateInput.value_number !== undefined && { + value_number: updateInput.value_number ?? undefined, + }), + ...(updateInput.value_boolean !== undefined && { + value_boolean: updateInput.value_boolean ?? undefined, + }), + ...(updateInput.value_date !== undefined && { value_date: updateInput.value_date ?? undefined }), + ...(updateInput.language !== undefined && { language: updateInput.language ?? undefined }), + ...(updateInput.metadata !== undefined && { metadata: updateInput.metadata }), + ...(updateInput.user_identifier !== undefined && { + user_identifier: updateInput.user_identifier ?? undefined, + }), + }; - const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload); + const updateResult = await updateFeedbackRecord(parsedInput.recordId, updateParams); if (!updateResult.data || updateResult.error) { throw new Error(updateResult.error?.message || "Failed to update feedback record"); } diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-record-form-drawer.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-record-form-drawer.tsx similarity index 83% rename from apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-record-form-drawer.tsx rename to apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-record-form-drawer.tsx index ea8af3f6ed..9d1e3299c6 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-record-form-drawer.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-record-form-drawer.tsx @@ -6,8 +6,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; -import { v7 as uuidv7 } from "uuid"; -import { z } from "zod"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import type { FeedbackRecordData } from "@/modules/hub/types"; import { AlertDialog } from "@/modules/ui/components/alert-dialog"; @@ -41,7 +39,24 @@ import { createFeedbackRecordAction, retrieveFeedbackRecordAction, updateFeedbackRecordAction, -} from "./actions"; +} from "../actions"; +import { + FIELD_TYPE_OPTIONS, + SOURCE_TYPE_CUSTOM_VALUE, + SOURCE_TYPE_PRESET_OPTIONS, + type TFeedbackRecordFormValues, + ZFeedbackRecordFormValues, +} from "../lib/types"; +import { + formatSourceType, + getCreateDefaults, + getReadOnlyMetadataEntries, + getValueFieldByType, + isPresetSourceType, + mapRecordToValues, + parseNumberValue, + toISOOrUndefined, +} from "../lib/utils"; type FeedbackRecordDrawerMode = "create" | "edit"; @@ -56,198 +71,6 @@ interface FeedbackRecordFormDrawerProps { onSuccess: () => Promise | void; } -const FIELD_TYPE_OPTIONS = [ - "text", - "categorical", - "nps", - "csat", - "ces", - "rating", - "number", - "boolean", - "date", -] as const; - -const SOURCE_TYPE_PRESET_OPTIONS = [ - "survey", - "review", - "feedback_form", - "support", - "social", - "interview", - "usability_test", - "nps_campaign", -] as const; - -const SOURCE_TYPE_CUSTOM_VALUE = "__custom__"; - -const ZMetadataEntry = z.object({ - key: z.string().trim().min(1), - value: z.string(), -}); - -const ZFeedbackRecordFormValues = z.object({ - id: z.string().optional(), - tenant_id: z.string().min(1), - submission_id: z.string().min(1), - collected_at: z.string().min(1), - created_at: z.string().optional(), - updated_at: z.string().optional(), - source_type: z.string().min(1), - source_id: z.string().optional(), - source_name: z.string().optional(), - field_id: z.string().min(1), - field_label: z.string().optional(), - field_type: z.enum(FIELD_TYPE_OPTIONS), - field_group_id: z.string().optional(), - field_group_label: z.string().optional(), - value_text: z.string().optional(), - value_number: z.string().optional(), - value_boolean: z.boolean().optional(), - value_date: z.string().optional(), - language: z.string().optional(), - user_identifier: z.string().optional(), - metadataEntries: z.array(ZMetadataEntry), -}); - -type TFeedbackRecordFormValues = z.infer; - -const getValueFieldByType = ( - fieldType: TFeedbackRecordFormValues["field_type"] -): "value_text" | "value_number" | "value_boolean" | "value_date" => { - switch (fieldType) { - case "boolean": - return "value_boolean"; - case "date": - return "value_date"; - case "nps": - case "csat": - case "ces": - case "rating": - case "number": - return "value_number"; - default: - return "value_text"; - } -}; - -const toLocalDateTimeInput = (isoDate: string): string => { - const date = new Date(isoDate); - if (!Number.isFinite(date.getTime())) { - return ""; - } - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - -const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => { - if (!dateTimeValue) { - return undefined; - } - - const parsed = new Date(dateTimeValue); - if (!Number.isFinite(parsed.getTime())) { - return undefined; - } - - return parsed.toISOString(); -}; - -const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => { - const now = new Date(); - const defaultDirectoryId = directories[0]?.id ?? ""; - - return { - id: "", - tenant_id: defaultDirectoryId, - submission_id: uuidv7(), - collected_at: toLocalDateTimeInput(now.toISOString()), - created_at: "", - updated_at: "", - source_type: "survey", - source_id: "", - source_name: "", - field_id: "", - field_label: "", - field_type: "text", - field_group_id: "", - field_group_label: "", - value_text: "", - value_number: "", - value_boolean: undefined, - value_date: "", - language: "", - user_identifier: "", - metadataEntries: [], - }; -}; - -const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => { - const metadataEntries = Object.entries(record.metadata ?? {}) - .filter(([, value]) => typeof value === "string") - .map(([key, value]) => ({ - key, - value: value as string, - })); - - return { - id: record.id, - tenant_id: record.tenant_id, - submission_id: record.submission_id, - collected_at: toLocalDateTimeInput(record.collected_at), - created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "", - updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "", - source_type: record.source_type, - source_id: record.source_id ?? "", - source_name: record.source_name ?? "", - field_id: record.field_id, - field_label: record.field_label ?? "", - field_type: record.field_type, - field_group_id: record.field_group_id ?? "", - field_group_label: record.field_group_label ?? "", - value_text: record.value_text ?? "", - value_number: record.value_number == null ? "" : String(record.value_number), - value_boolean: record.value_boolean, - value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "", - language: record.language ?? "", - user_identifier: record.user_identifier ?? "", - metadataEntries, - }; -}; - -const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => { - return Object.entries(record.metadata ?? {}) - .filter(([, value]) => typeof value !== "string") - .map(([key, value]) => ({ - key, - value: JSON.stringify(value), - })); -}; - -const parseNumberValue = (value: string): number | null => { - if (value.trim() === "") return null; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -}; - -const formatSourceType = (sourceType: string, t: (key: string) => string): string => { - switch (sourceType) { - case "formbricks": - case "formbricks_survey": - return t("workspace.unify.formbricks_surveys"); - case "csv": - return t("workspace.unify.csv_import"); - default: - return sourceType; - } -}; - export const FeedbackRecordFormDrawer = ({ mode, open, @@ -316,14 +139,9 @@ export const FeedbackRecordFormDrawer = ({ setRecord(result.data); form.reset(mapRecordToValues(result.data)); - setSourceTypeMode( - SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) - ? result.data.source_type - : SOURCE_TYPE_CUSTOM_VALUE - ); - setCustomSourceType( - SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type - ); + const isPreset = isPresetSourceType(result.data.source_type); + setSourceTypeMode(isPreset ? result.data.source_type : SOURCE_TYPE_CUSTOM_VALUE); + setCustomSourceType(isPreset ? "" : result.data.source_type); setIsLoadingRecord(false); }; @@ -442,7 +260,15 @@ export const FeedbackRecordFormDrawer = ({ Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string") ); - const updatePayload: Record = { + const updatePayload: { + language: string | null; + user_identifier: string | null; + metadata: Record; + value_text?: string; + value_number?: number | null; + value_boolean?: boolean | null; + value_date?: string | null; + } = { language: values.language?.trim() || null, user_identifier: values.user_identifier?.trim() || null, metadata: { ...preservedMetadata, ...metadata }, @@ -461,7 +287,7 @@ export const FeedbackRecordFormDrawer = ({ const updateResult = await updateFeedbackRecordAction({ workspaceId, recordId, - updateInput: updatePayload as never, + updateInput: updatePayload, }); if (!updateResult?.data) { diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-page-client.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-page-client.tsx similarity index 93% rename from apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-page-client.tsx rename to apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-page-client.tsx index 6a93a84e23..55f6bc8e9a 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-page-client.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-page-client.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import type { FeedbackRecordData } from "@/modules/hub/types"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; -import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation"; +import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation"; import { FeedbackRecordsTable } from "./feedback-records-table"; interface FeedbackRecordsPageClientProps { diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx similarity index 93% rename from apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx rename to apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx index c870a4a893..94c96a042f 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/feedback-records-table.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx @@ -29,7 +29,8 @@ import { DropdownMenuTrigger, } from "@/modules/ui/components/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; -import { CsvImportModal } from "../sources/components/csv-import-modal"; +import { CsvImportModal } from "../../sources/components/csv-import-modal"; +import { formatSourceType } from "../lib/utils"; import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer"; const RECORDS_PER_PAGE = 50; @@ -54,18 +55,6 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): return "—"; }; -const formatSourceType = (sourceType: string, t: TFunction): string => { - switch (sourceType) { - case "formbricks": - case "formbricks_survey": - return t("workspace.unify.formbricks_surveys"); - case "csv": - return t("workspace.unify.csv_import"); - default: - return sourceType; - } -}; - function truncate(str: string, maxLen: number): string { if (str.length <= maxLen) return str; return str.slice(0, maxLen) + "…"; @@ -102,22 +91,6 @@ export const FeedbackRecordsTable = ({ .sort((a, b) => a.name.localeCompare(b.name)), [frdMap] ); - const feedbackDirectoryName = useMemo(() => { - const directoryNames = Array.from( - new Set( - records - .map((record) => frdMap[record.tenant_id]) - .filter((directoryName): directoryName is string => Boolean(directoryName)) - ) - ); - - if (directoryNames.length > 0) { - return directoryNames.join(", "); - } - - return directories[0]?.name ?? "—"; - }, [directories, frdMap, records]); - const handleRefresh = async () => { if (isRefreshing) return; setIsRefreshing(true); @@ -195,7 +168,6 @@ export const FeedbackRecordsTable = ({

{t("workspace.unify.showing_count_loaded", { count: records.length, - directoryName: feedbackDirectoryName, })}

)} diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/types.ts b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/types.ts new file mode 100644 index 0000000000..62785f529a --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/types.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const FIELD_TYPE_OPTIONS = [ + "text", + "categorical", + "nps", + "csat", + "ces", + "rating", + "number", + "boolean", + "date", +] as const; + +export const SOURCE_TYPE_PRESET_OPTIONS = [ + "survey", + "review", + "feedback_form", + "support", + "social", + "interview", + "usability_test", + "nps_campaign", +] as const; + +export const SOURCE_TYPE_CUSTOM_VALUE = "__custom__"; + +const ZMetadataEntry = z.object({ + key: z.string().trim().min(1), + value: z.string(), +}); + +export const ZFeedbackRecordFormValues = z.object({ + id: z.string().optional(), + tenant_id: z.string().min(1), + submission_id: z.string().min(1), + collected_at: z.string().min(1), + created_at: z.string().optional(), + updated_at: z.string().optional(), + source_type: z.string().min(1), + source_id: z.string().optional(), + source_name: z.string().optional(), + field_id: z.string().min(1), + field_label: z.string().optional(), + field_type: z.enum(FIELD_TYPE_OPTIONS), + field_group_id: z.string().optional(), + field_group_label: z.string().optional(), + value_text: z.string().optional(), + value_number: z.string().optional(), + value_boolean: z.boolean().optional(), + value_date: z.string().optional(), + language: z.string().optional(), + user_identifier: z.string().optional(), + metadataEntries: z.array(ZMetadataEntry), +}); + +export type TFeedbackRecordFormValues = z.infer; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.ts b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.ts new file mode 100644 index 0000000000..a0eb6e9ea0 --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.ts @@ -0,0 +1,143 @@ +import { TFunction } from "i18next"; +import { v7 as uuidv7 } from "uuid"; +import type { FeedbackRecordData } from "@/modules/hub/types"; +import { SOURCE_TYPE_PRESET_OPTIONS, type TFeedbackRecordFormValues } from "./types"; + +export const getValueFieldByType = ( + fieldType: TFeedbackRecordFormValues["field_type"] +): "value_text" | "value_number" | "value_boolean" | "value_date" => { + switch (fieldType) { + case "boolean": + return "value_boolean"; + case "date": + return "value_date"; + case "nps": + case "csat": + case "ces": + case "rating": + case "number": + return "value_number"; + default: + return "value_text"; + } +}; + +export const toLocalDateTimeInput = (isoDate: string): string => { + const date = new Date(isoDate); + if (!Number.isFinite(date.getTime())) { + return ""; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + + return `${year}-${month}-${day}T${hours}:${minutes}`; +}; + +export const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => { + if (!dateTimeValue) { + return undefined; + } + + const parsed = new Date(dateTimeValue); + if (!Number.isFinite(parsed.getTime())) { + return undefined; + } + + return parsed.toISOString(); +}; + +export const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => { + const now = new Date(); + const defaultDirectoryId = directories[0]?.id ?? ""; + + return { + id: "", + tenant_id: defaultDirectoryId, + submission_id: uuidv7(), + collected_at: toLocalDateTimeInput(now.toISOString()), + created_at: "", + updated_at: "", + source_type: "survey", + source_id: "", + source_name: "", + field_id: "", + field_label: "", + field_type: "text", + field_group_id: "", + field_group_label: "", + value_text: "", + value_number: "", + value_boolean: undefined, + value_date: "", + language: "", + user_identifier: "", + metadataEntries: [], + }; +}; + +export const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => { + const metadataEntries = Object.entries(record.metadata ?? {}) + .filter(([, value]) => typeof value === "string") + .map(([key, value]) => ({ + key, + value: value as string, + })); + + return { + id: record.id, + tenant_id: record.tenant_id, + submission_id: record.submission_id, + collected_at: toLocalDateTimeInput(record.collected_at), + created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "", + updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "", + source_type: record.source_type, + source_id: record.source_id ?? "", + source_name: record.source_name ?? "", + field_id: record.field_id, + field_label: record.field_label ?? "", + field_type: record.field_type, + field_group_id: record.field_group_id ?? "", + field_group_label: record.field_group_label ?? "", + value_text: record.value_text ?? "", + value_number: record.value_number == null ? "" : String(record.value_number), + value_boolean: record.value_boolean, + value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "", + language: record.language ?? "", + user_identifier: record.user_identifier ?? "", + metadataEntries, + }; +}; + +export const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => { + return Object.entries(record.metadata ?? {}) + .filter(([, value]) => typeof value !== "string") + .map(([key, value]) => ({ + key, + value: JSON.stringify(value), + })); +}; + +export const parseNumberValue = (value: string): number | null => { + if (value.trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const isPresetSourceType = (value: string): value is (typeof SOURCE_TYPE_PRESET_OPTIONS)[number] => + (SOURCE_TYPE_PRESET_OPTIONS as readonly string[]).includes(value); + +export const formatSourceType = (sourceType: string, t: TFunction): string => { + switch (sourceType) { + case "formbricks": + case "formbricks_survey": + return t("workspace.unify.formbricks_surveys"); + case "csv": + return t("workspace.unify.csv_import"); + default: + return sourceType; + } +}; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx index 218e695e84..b3e96e61e2 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx @@ -4,7 +4,7 @@ import { getTranslate } from "@/lingodotdev/server"; import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory"; import { listFeedbackRecords } from "@/modules/hub/service"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; -import { FeedbackRecordsPageClient } from "./feedback-records-page-client"; +import { FeedbackRecordsPageClient } from "./components/feedback-records-page-client"; const INITIAL_PAGE_SIZE = 50; diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index e50530d1a7..738e3201e1 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -304,6 +304,7 @@ checksums: common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4 common/not_connected: 91ebf07fff6b2ead94d85bd17212e0ba + common/not_set: 380482630d60ee2d1531b31246caa467 common/note: e0337f202c911423275f834edeffc54b common/notifications: c52df856139b50dbb1cae7bfb1cf73bb common/number: 2789f8391f63e7200a5521078aab017d @@ -404,6 +405,7 @@ checksums: common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0 common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836 + common/soon: b12e79beb0aef9414a445a1b95dd4322 common/sort_by: 8adf3dbc5668379558957662f0c43563 common/start_free_trial: e346e4ed7d138dcc873db187922369da common/status: 4e1fcce15854d824919b4a582c697c90 @@ -2441,10 +2443,14 @@ checksums: workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31 workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff + workspace/settings/feedback_record_directories/pause_connectors_confirmation_description: a3c2c56daed9f2a9e6a853cb8b924bad + workspace/settings/feedback_record_directories/pause_connectors_confirmation_title: 09041363c55fb2686f8115df6fa2afc1 workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168 + workspace/settings/feedback_record_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76 + workspace/settings/feedback_record_directories/workspace_access: 32407b39cf878fb579559c1ed3660892 workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525 workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83 workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5 @@ -3453,12 +3459,15 @@ checksums: workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02 workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0 + workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0 + workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0 workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45 workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2 workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb workspace/unify/api_ingestion_manage_api_keys: 116786a004fb7b16ead8a5b7a6a2debe workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3 + workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316 workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b @@ -3483,8 +3492,12 @@ checksums: workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27 workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6 workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf + workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da + workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110 + workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941 + workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353 workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9 @@ -3494,15 +3507,23 @@ checksums: workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8 workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400 + workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1 + workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda + workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064 workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803 + workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826 + workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0 workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388 workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1 workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2 workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb workspace/unify/feedback_sources_directory_access_single: c9da6b30d410a0ca6302a00a5747dc19 workspace/unify/feedback_sources_settings_description: 45f162f2f81cd195c23cb3ec490bb3df + workspace/unify/field_group_id: 17024bb46ff1e088afb6a279dc85aad4 + workspace/unify/field_group_label: 3df09c3b6fd22310359cf955ecff5c8e + workspace/unify/field_id: 7791b5d581b7a525dcadf11ec73c6ab7 workspace/unify/field_label: 6384505ca0e40010c666b712511132a6 workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08 workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a @@ -3513,12 +3534,18 @@ checksums: workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4 workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0 workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff + workspace/unify/import_via_source_name: eae32ae2fc87f925ca016fe8283bcbfd workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984 workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20 workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6 workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e workspace/unify/manage_directories: 460e00e1cbf1f51de57a2548546e33d7 + workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8 + workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028 + workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6 + workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602 + workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216 workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5 workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824 workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693 @@ -3535,6 +3562,7 @@ checksums: workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20 workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862 workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d + workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56 workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475 @@ -3549,15 +3577,17 @@ checksums: workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95 workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be + workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9 workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61 workspace/unify/source_type: d1ff69af76c687eb189db72030717570 workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1 - workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006 workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248 workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939 workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991 workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48 + workspace/unify/submission_id: 02edf76883b47079dbe20f3f36b7c1a7 workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d + workspace/unify/topics_and_subtopics: 1148eca01a1993fadca932efcdea7641 workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5 workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396 workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724 @@ -3565,6 +3595,10 @@ checksums: workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2 workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b + workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab + workspace/unify/value_date: c8d705d1975affc01c002324725fec3f + workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675 + workspace/unify/value_text: e097a597cc507c716401ad18255de578 workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9 workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935 workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 86d0c031f2..6eb1aeb9ff 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -3741,7 +3741,6 @@ "source_name": "Quellenname", "source_type": "Quellentyp", "source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden", - "sources": "Quellen", "status_error": "Fehler", "status_live_sync": "Live-Synchronisierung", "status_paused": "Pausiert", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index c4d180231e..95acb0b4a9 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -3741,7 +3741,6 @@ "source_name": "Nombre de origen", "source_type": "Tipo de fuente", "source_type_cannot_be_changed": "El tipo de origen no se puede cambiar", - "sources": "Orígenes", "status_error": "Error", "status_live_sync": "Sincronización en vivo", "status_paused": "Pausado", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 69157281c3..f9c2ecffd5 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -3741,7 +3741,6 @@ "source_name": "Nom de la source", "source_type": "Type de source", "source_type_cannot_be_changed": "Le type de source ne peut pas être modifié", - "sources": "Sources", "status_error": "Erreur", "status_live_sync": "Synchronisation en direct", "status_paused": "En pause", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 2edb493ba5..2f9340d7ba 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -3741,7 +3741,6 @@ "source_name": "Forrásnév", "source_type": "Forrás típus", "source_type_cannot_be_changed": "A forrástípus nem módosítható", - "sources": "Források", "status_error": "Hiba", "status_live_sync": "Élő szinkronizálás", "status_paused": "Szüneteltetve", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index eba9965d2e..114e9e5a13 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -3741,7 +3741,6 @@ "source_name": "ソース名", "source_type": "ソースタイプ", "source_type_cannot_be_changed": "ソースタイプは変更できません", - "sources": "ソース", "status_error": "エラー", "status_live_sync": "リアルタイム同期", "status_paused": "一時停止", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 9c208f8518..59831e316c 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -3741,7 +3741,6 @@ "source_name": "Bronnaam", "source_type": "Brontype", "source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd", - "sources": "Bronnen", "status_error": "Fout", "status_live_sync": "Live synchronisatie", "status_paused": "Gepauzeerd", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index cf767e9d75..ddb9572164 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -3741,7 +3741,6 @@ "source_name": "Nome da origem", "source_type": "Tipo de fonte", "source_type_cannot_be_changed": "O tipo de origem não pode ser alterado", - "sources": "Origens", "status_error": "Erro", "status_live_sync": "Sincronização ao vivo", "status_paused": "Pausado", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 098e31bc1c..26fe577c50 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -3741,7 +3741,6 @@ "source_name": "Nome da fonte", "source_type": "Tipo de fonte", "source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado", - "sources": "Fontes", "status_error": "Erro", "status_live_sync": "Sincronização em direto", "status_paused": "Em pausa", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 3c33ec9b03..dc75e7a2ed 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -3741,7 +3741,6 @@ "source_name": "Nume sursă", "source_type": "Tip sursă", "source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat", - "sources": "Surse", "status_error": "Eroare", "status_live_sync": "Sincronizare în timp real", "status_paused": "Pauzat", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index fa48cdce67..cfaee733d0 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -3741,7 +3741,6 @@ "source_name": "Имя источника", "source_type": "Тип источника", "source_type_cannot_be_changed": "Тип источника нельзя изменить", - "sources": "Источники", "status_error": "Ошибка", "status_live_sync": "Синхронизация в реальном времени", "status_paused": "Приостановлен", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 7b7fbd30c8..eaac69ebcd 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -3741,7 +3741,6 @@ "source_name": "Källnamn", "source_type": "Källtyp", "source_type_cannot_be_changed": "Källtyp kan inte ändras", - "sources": "Källor", "status_error": "Fel", "status_live_sync": "Live sync", "status_paused": "Pausad", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 88a8a992a9..b443bd8378 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -3741,7 +3741,6 @@ "source_name": "Kaynak Adı", "source_type": "Kaynak Türü", "source_type_cannot_be_changed": "Kaynak türü değiştirilemez", - "sources": "Kaynaklar", "status_error": "Hata", "status_live_sync": "Live sync", "status_paused": "Duraklatıldı", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 986f76705d..c852386f4c 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -3741,7 +3741,6 @@ "source_name": "来源名称", "source_type": "来源类型", "source_type_cannot_be_changed": "来源类型无法更改", - "sources": "来源", "status_error": "错误", "status_live_sync": "Live sync", "status_paused": "已暂停", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 82d29e935b..3194c69454 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -3741,7 +3741,6 @@ "source_name": "來源名稱", "source_type": "來源類型", "source_type_cannot_be_changed": "來源類型無法變更", - "sources": "來源", "status_error": "錯誤", "status_live_sync": "Live sync", "status_paused": "已暫停", From 1ffb5d607f64181dccd03aa304c0a278c44b96f3 Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Tue, 28 Apr 2026 16:12:03 +0530 Subject: [PATCH 5/6] test: add coverage for feedback-records utils and hub service retrieve/update - Add utils.test.ts with 32 tests covering all exported functions: getValueFieldByType, toLocalDateTimeInput, toISOOrUndefined, getCreateDefaults, mapRecordToValues, getReadOnlyMetadataEntries, parseNumberValue, isPresetSourceType, formatSourceType - Add 6 tests to hub service.test.ts for retrieveFeedbackRecord and updateFeedbackRecord (null client, success, error cases) Co-Authored-By: Claude Opus 4.6 --- .../unify/feedback-records/lib/utils.test.ts | 169 ++++++++++++++++++ apps/web/modules/hub/service.test.ts | 64 ++++++- 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.test.ts diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.test.ts new file mode 100644 index 0000000000..08a70f568b --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/lib/utils.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test, vi } from "vitest"; +import type { FeedbackRecordData } from "@/modules/hub/types"; +import { + formatSourceType, + getCreateDefaults, + getReadOnlyMetadataEntries, + getValueFieldByType, + isPresetSourceType, + mapRecordToValues, + parseNumberValue, + toISOOrUndefined, + toLocalDateTimeInput, +} from "./utils"; + +vi.mock("uuid", () => ({ v7: () => "mock-uuid-v7" })); + +const makeRecord = (overrides: Partial = {}): FeedbackRecordData => ({ + id: "rec-1", + tenant_id: "tenant-1", + submission_id: "sub-1", + collected_at: "2026-03-15T12:00:00.000Z", + created_at: "2026-03-15T12:00:00.000Z", + updated_at: "2026-03-15T12:00:00.000Z", + source_type: "survey", + field_id: "f1", + field_type: "text", + ...overrides, +}); + +describe("getValueFieldByType", () => { + test.each([ + ["boolean", "value_boolean"], + ["date", "value_date"], + ["nps", "value_number"], + ["csat", "value_number"], + ["ces", "value_number"], + ["rating", "value_number"], + ["number", "value_number"], + ["text", "value_text"], + ["categorical", "value_text"], + ] as const)("returns %s → %s", (input, expected) => { + expect(getValueFieldByType(input)).toBe(expected); + }); +}); + +describe("toLocalDateTimeInput", () => { + test("formats valid ISO date", () => { + const result = toLocalDateTimeInput("2026-03-15T14:30:00.000Z"); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); + + test("returns empty string for invalid date", () => { + expect(toLocalDateTimeInput("not-a-date")).toBe(""); + }); +}); + +describe("toISOOrUndefined", () => { + test("returns ISO string for valid input", () => { + expect(toISOOrUndefined("2026-03-15T14:30")).toMatch(/2026-03-15/); + }); + + test("returns undefined for empty string", () => { + expect(toISOOrUndefined("")).toBeUndefined(); + }); + + test("returns undefined for undefined", () => { + expect(toISOOrUndefined(undefined)).toBeUndefined(); + }); + + test("returns undefined for invalid date", () => { + expect(toISOOrUndefined("not-a-date")).toBeUndefined(); + }); +}); + +describe("getCreateDefaults", () => { + test("uses first directory as tenant_id", () => { + const dirs = [{ id: "dir-1", name: "Dir 1" }]; + const result = getCreateDefaults(dirs); + expect(result.tenant_id).toBe("dir-1"); + expect(result.submission_id).toBe("mock-uuid-v7"); + expect(result.field_type).toBe("text"); + expect(result.metadataEntries).toEqual([]); + }); + + test("handles empty directories", () => { + const result = getCreateDefaults([]); + expect(result.tenant_id).toBe(""); + }); +}); + +describe("mapRecordToValues", () => { + test("maps a full record", () => { + const record = makeRecord({ + value_text: "hello", + value_number: 42, + source_id: "s1", + source_name: "Survey", + metadata: { tag: "vip", nested: { a: 1 } }, + }); + const result = mapRecordToValues(record); + expect(result.id).toBe("rec-1"); + expect(result.value_text).toBe("hello"); + expect(result.value_number).toBe("42"); + expect(result.source_id).toBe("s1"); + expect(result.metadataEntries).toEqual([{ key: "tag", value: "vip" }]); + }); + + test("handles nullish optional fields", () => { + const record = makeRecord({ value_number: undefined, source_id: undefined }); + const result = mapRecordToValues(record); + expect(result.value_number).toBe(""); + expect(result.source_id).toBe(""); + }); +}); + +describe("getReadOnlyMetadataEntries", () => { + test("returns only non-string metadata values", () => { + const record = makeRecord({ metadata: { tag: "vip", count: 5, nested: { a: 1 } } }); + const result = getReadOnlyMetadataEntries(record); + expect(result).toEqual([ + { key: "count", value: "5" }, + { key: "nested", value: '{"a":1}' }, + ]); + }); + + test("returns empty array when no metadata", () => { + expect(getReadOnlyMetadataEntries(makeRecord())).toEqual([]); + }); +}); + +describe("parseNumberValue", () => { + test.each([ + ["42", 42], + ["3.14", 3.14], + ["-1", -1], + ["", null], + [" ", null], + ["abc", null], + ["Infinity", null], + ])("parseNumberValue(%s) → %s", (input, expected) => { + expect(parseNumberValue(input)).toBe(expected); + }); +}); + +describe("isPresetSourceType", () => { + test("returns true for preset values", () => { + expect(isPresetSourceType("survey")).toBe(true); + expect(isPresetSourceType("nps_campaign")).toBe(true); + }); + + test("returns false for custom values", () => { + expect(isPresetSourceType("custom_type")).toBe(false); + expect(isPresetSourceType("")).toBe(false); + }); +}); + +describe("formatSourceType", () => { + const t = ((key: string) => key) as any; + + test("maps known source types", () => { + expect(formatSourceType("formbricks", t)).toBe("workspace.unify.formbricks_surveys"); + expect(formatSourceType("formbricks_survey", t)).toBe("workspace.unify.formbricks_surveys"); + expect(formatSourceType("csv", t)).toBe("workspace.unify.csv_import"); + }); + + test("returns raw value for unknown types", () => { + expect(formatSourceType("custom", t)).toBe("custom"); + }); +}); diff --git a/apps/web/modules/hub/service.test.ts b/apps/web/modules/hub/service.test.ts index d31df7ba2c..c43c19b3d3 100644 --- a/apps/web/modules/hub/service.test.ts +++ b/apps/web/modules/hub/service.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { createFeedbackRecord, createFeedbackRecordsBatch, listFeedbackRecords } from "./service"; +import { + createFeedbackRecord, + createFeedbackRecordsBatch, + listFeedbackRecords, + retrieveFeedbackRecord, + updateFeedbackRecord, +} from "./service"; import type { FeedbackRecordCreateParams } from "./types"; vi.mock("@formbricks/logger", () => ({ @@ -121,6 +127,62 @@ describe("hub service", () => { }); }); + describe("retrieveFeedbackRecord", () => { + test("returns error when client is null", async () => { + vi.mocked(getHubClient).mockReturnValue(null); + const result = await retrieveFeedbackRecord("rec-1"); + expect(result.data).toBeNull(); + expect(result.error?.message).toContain("HUB_API_KEY"); + }); + + test("returns data on success", async () => { + const record = { id: "rec-1", field_id: "f1" }; + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { retrieve: vi.fn().mockResolvedValue(record) }, + } as any); + const result = await retrieveFeedbackRecord("rec-1"); + expect(result.data).toEqual(record); + expect(result.error).toBeNull(); + }); + + test("returns error on throw", async () => { + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { retrieve: vi.fn().mockRejectedValue(new Error("Not found")) }, + } as any); + const result = await retrieveFeedbackRecord("rec-1"); + expect(result.data).toBeNull(); + expect(result.error).toMatchObject({ message: "Not found" }); + }); + }); + + describe("updateFeedbackRecord", () => { + test("returns error when client is null", async () => { + vi.mocked(getHubClient).mockReturnValue(null); + const result = await updateFeedbackRecord("rec-1", { value_text: "new" }); + expect(result.data).toBeNull(); + expect(result.error?.message).toContain("HUB_API_KEY"); + }); + + test("returns data on success", async () => { + const updated = { id: "rec-1", value_text: "new" }; + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { update: vi.fn().mockResolvedValue(updated) }, + } as any); + const result = await updateFeedbackRecord("rec-1", { value_text: "new" }); + expect(result.data).toEqual(updated); + expect(result.error).toBeNull(); + }); + + test("returns error on throw", async () => { + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { update: vi.fn().mockRejectedValue(new Error("Forbidden")) }, + } as any); + const result = await updateFeedbackRecord("rec-1", { value_text: "new" }); + expect(result.data).toBeNull(); + expect(result.error).toMatchObject({ message: "Forbidden" }); + }); + }); + describe("createFeedbackRecordsBatch", () => { test("returns all errors when getHubClient returns null", async () => { vi.mocked(getHubClient).mockReturnValue(null); From 963f89c524a7e4a50baf39b46389c8c818d974ec Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Wed, 29 Apr 2026 10:30:41 +0530 Subject: [PATCH 6/6] refactor: use toSorted and remove redundant type alias in feedback records Replace .sort() with .toSorted() to avoid mutating arrays in-place during chaining, and remove the redundant CreateFeedbackRecordResult type alias in favor of HubFeedbackRecordResult. Co-Authored-By: Claude Opus 4.6 --- .../feedback-records/components/feedback-records-table.tsx | 2 +- .../workspaces/[workspaceId]/unify/feedback-records/page.tsx | 2 +- apps/web/modules/hub/index.ts | 1 - apps/web/modules/hub/service.ts | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx index 94c96a042f..5e2791b7e6 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/components/feedback-records-table.tsx @@ -121,7 +121,7 @@ export const FeedbackRecordsTable = ({ } const mergedRecords = successfulRecords - .sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1)) + .toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1)) .slice(0, RECORDS_PER_PAGE); setRecords(mergedRecords); setIsRefreshing(false); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx index b3e96e61e2..fa2de1d503 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/unify/feedback-records/page.tsx @@ -41,7 +41,7 @@ export default async function UnifyFeedbackRecordsPage( const merged = successfulResults .flatMap((r) => r.data?.data ?? []) - .sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1)) + .toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1)) .slice(0, INITIAL_PAGE_SIZE); const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name])); diff --git a/apps/web/modules/hub/index.ts b/apps/web/modules/hub/index.ts index ff66407205..92322267e4 100644 --- a/apps/web/modules/hub/index.ts +++ b/apps/web/modules/hub/index.ts @@ -5,7 +5,6 @@ export { listFeedbackRecords, retrieveFeedbackRecord, updateFeedbackRecord, - type CreateFeedbackRecordResult, type HubFeedbackRecordResult, type ListFeedbackRecordsResult, } from "./service"; diff --git a/apps/web/modules/hub/service.ts b/apps/web/modules/hub/service.ts index d5070ac620..4eef9ecb48 100644 --- a/apps/web/modules/hub/service.ts +++ b/apps/web/modules/hub/service.ts @@ -16,7 +16,6 @@ export type HubFeedbackRecordResult = { data: FeedbackRecordData | null; error: HubError | null; }; -export type CreateFeedbackRecordResult = HubFeedbackRecordResult; const NO_CONFIG_ERROR = { status: 0,