mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-04 10:19:31 -06:00
fixes
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
@@ -13,11 +14,12 @@ export const UnifyConfigNavigation = ({
|
||||
activeId: activeIdProp,
|
||||
loading,
|
||||
}: UnifyConfigNavigationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/environments/${environmentId}/workspace/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "sources";
|
||||
|
||||
const navigation = [{ id: "sources", label: "Sources", href: `${baseHref}/sources` }];
|
||||
const navigation = [{ id: "sources", label: t("environments.unify.sources"), href: `${baseHref}/sources` }];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon, PlusIcon, SparklesIcon, WebhookIcon } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { PlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -18,10 +17,7 @@ import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
AI_SUGGESTED_MAPPINGS,
|
||||
EMAIL_SOURCE_FIELDS,
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
SAMPLE_WEBHOOK_PAYLOAD,
|
||||
TCreateSourceStep,
|
||||
TFieldMapping,
|
||||
TSourceConnection,
|
||||
@@ -29,28 +25,12 @@ import {
|
||||
TSourceType,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
import { parseCSVColumnsToFields, parsePayloadToFields } from "../utils";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
import { CsvSourceUI } from "./csv-source-ui";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
import { SourceTypeSelector } from "./source-type-selector";
|
||||
|
||||
// Polling interval in milliseconds (3 seconds)
|
||||
const WEBHOOK_POLL_INTERVAL = 3000;
|
||||
|
||||
// Sample webhook payload for cURL example
|
||||
const SAMPLE_CURL_PAYLOAD = {
|
||||
timestamp: new Date().toISOString(),
|
||||
source_type: "webhook",
|
||||
field_id: "satisfaction_score",
|
||||
field_type: "rating",
|
||||
value_number: 4,
|
||||
user_id: "user_123",
|
||||
metadata: {
|
||||
source: "api",
|
||||
},
|
||||
};
|
||||
|
||||
interface CreateSourceModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -66,134 +46,33 @@ function getDefaultSourceName(type: TSourceType): string {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks Survey Connection";
|
||||
case "webhook":
|
||||
return "Webhook Connection";
|
||||
case "email":
|
||||
return "Email Connection";
|
||||
case "csv":
|
||||
return "CSV Import";
|
||||
case "slack":
|
||||
return "Slack Connection";
|
||||
default:
|
||||
return "New Source";
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys }: CreateSourceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState<TCreateSourceStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TSourceType | null>(null);
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||
|
||||
// Formbricks-specific state
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
||||
|
||||
// Webhook listener state
|
||||
const [webhookSessionId, setWebhookSessionId] = useState<string | null>(null);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [webhookReceived, setWebhookReceived] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Generate webhook URL
|
||||
const webhookUrl = webhookSessionId
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/unify/webhook-listener/${webhookSessionId}`
|
||||
: "";
|
||||
|
||||
// Poll for webhook payload
|
||||
const pollForWebhook = useCallback(async () => {
|
||||
if (!webhookSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/unify/webhook-listener/${webhookSessionId}`);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
if (data.payload) {
|
||||
// Parse the received payload into source fields
|
||||
const fields = parsePayloadToFields(data.payload);
|
||||
setSourceFields(fields);
|
||||
setWebhookReceived(true);
|
||||
setIsListening(false);
|
||||
toast.success("Webhook received! Fields loaded.");
|
||||
|
||||
// Stop polling
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 204 means no payload yet, keep polling
|
||||
} catch (error) {
|
||||
console.error("Error polling for webhook:", error);
|
||||
}
|
||||
}, [webhookSessionId]);
|
||||
|
||||
// Start/stop polling based on listening state
|
||||
useEffect(() => {
|
||||
if (isListening && webhookSessionId) {
|
||||
// Start polling
|
||||
pollingIntervalRef.current = setInterval(pollForWebhook, WEBHOOK_POLL_INTERVAL);
|
||||
// Also poll immediately
|
||||
pollForWebhook();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup polling on unmount or when listening stops
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isListening, webhookSessionId, pollForWebhook]);
|
||||
|
||||
// Generate session ID when webhook type is selected and modal opens
|
||||
useEffect(() => {
|
||||
if (open && selectedType === "webhook" && currentStep === "mapping" && !webhookSessionId) {
|
||||
setWebhookSessionId(nanoid(21));
|
||||
setIsListening(true);
|
||||
}
|
||||
}, [open, selectedType, currentStep, webhookSessionId]);
|
||||
|
||||
// Copy cURL command to clipboard
|
||||
const handleCopyWebhookUrl = async () => {
|
||||
if (!webhookUrl) return;
|
||||
const curlCommand = `curl -X POST \\
|
||||
"${webhookUrl}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(curlCommand);
|
||||
setCopied(true);
|
||||
toast.success("cURL command copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
setSourceName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setDeriveFromAttachments(false);
|
||||
setSelectedSurveyId(null);
|
||||
setSelectedElementIds([]);
|
||||
// Reset webhook state
|
||||
setWebhookSessionId(null);
|
||||
setIsListening(false);
|
||||
setWebhookReceived(false);
|
||||
setCopied(false);
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -203,10 +82,11 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const isDisabledType = (type: TSourceType) => type === "webhook" || type === "email" || type === "slack";
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep === "selectType" && selectedType && selectedType !== "slack") {
|
||||
if (currentStep === "selectType" && selectedType && !isDisabledType(selectedType)) {
|
||||
if (selectedType === "formbricks") {
|
||||
// For Formbricks, use the survey name if selected
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
setSourceName(
|
||||
selectedSurvey ? `${selectedSurvey.name} Connection` : getDefaultSourceName(selectedType)
|
||||
@@ -218,7 +98,6 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
}
|
||||
};
|
||||
|
||||
// Formbricks handlers
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
@@ -251,7 +130,6 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
const handleCreateSource = () => {
|
||||
if (!selectedType || !sourceName.trim()) return;
|
||||
|
||||
// Check if all required fields are mapped (for non-Formbricks connectors)
|
||||
if (selectedType !== "formbricks") {
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
@@ -259,7 +137,6 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
);
|
||||
|
||||
if (!allRequiredMapped) {
|
||||
// For now, we'll allow creating without all required fields for POC
|
||||
console.warn("Not all required fields are mapped");
|
||||
}
|
||||
}
|
||||
@@ -273,7 +150,6 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Pass the Formbricks-specific data if applicable
|
||||
onCreateSource(
|
||||
newSource,
|
||||
selectedType === "formbricks" ? (selectedSurveyId ?? undefined) : undefined,
|
||||
@@ -288,26 +164,15 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||
);
|
||||
|
||||
// Formbricks validation - need survey and at least one element selected
|
||||
const isFormbricksValid =
|
||||
selectedType === "formbricks" && selectedSurveyId && selectedElementIds.length > 0;
|
||||
|
||||
// CSV validation - need sourceFields loaded (CSV uploaded or sample loaded)
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (!selectedType) return;
|
||||
let fields: TSourceField[];
|
||||
if (selectedType === "webhook") {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
} else if (selectedType === "email") {
|
||||
fields = EMAIL_SOURCE_FIELDS;
|
||||
} else if (selectedType === "csv") {
|
||||
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
} else {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
if (selectedType === "csv") {
|
||||
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
|
||||
setSourceFields(fields);
|
||||
}
|
||||
setSourceFields(fields);
|
||||
};
|
||||
|
||||
const handleSuggestMapping = () => {
|
||||
@@ -317,7 +182,6 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
|
||||
const newMappings: TFieldMapping[] = [];
|
||||
|
||||
// Add field mappings from source fields
|
||||
for (const sourceField of sourceFields) {
|
||||
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
|
||||
if (suggestedTarget) {
|
||||
@@ -331,7 +195,6 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
}
|
||||
}
|
||||
|
||||
// Add static value mappings
|
||||
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
|
||||
if (targetExists) {
|
||||
@@ -347,23 +210,10 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const getLoadButtonLabel = () => {
|
||||
switch (selectedType) {
|
||||
case "webhook":
|
||||
return "Simulate webhook";
|
||||
case "email":
|
||||
return "Load email fields";
|
||||
case "csv":
|
||||
return "Load sample CSV";
|
||||
default:
|
||||
return "Load sample";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
Add source
|
||||
{t("environments.unify.add_source")}
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -372,21 +222,21 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{currentStep === "selectType"
|
||||
? "Add Feedback Source"
|
||||
? t("environments.unify.add_feedback_source")
|
||||
: selectedType === "formbricks"
|
||||
? "Select Survey & Questions"
|
||||
? t("environments.unify.select_survey_and_questions")
|
||||
: selectedType === "csv"
|
||||
? "Import CSV Data"
|
||||
: "Configure Mapping"}
|
||||
? t("environments.unify.import_csv_data")
|
||||
: t("environments.unify.configure_mapping")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentStep === "selectType"
|
||||
? "Select the type of feedback source you want to connect."
|
||||
? t("environments.unify.select_source_type_description")
|
||||
: selectedType === "formbricks"
|
||||
? "Choose which survey questions should create FeedbackRecords."
|
||||
? t("environments.unify.select_survey_questions_description")
|
||||
: selectedType === "csv"
|
||||
? "Upload a CSV file or set up automated S3 imports."
|
||||
: "Map source fields to Hub Feedback Record fields."}
|
||||
? t("environments.unify.upload_csv_data_description")
|
||||
: t("environments.unify.configure_mapping")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -394,15 +244,14 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
{currentStep === "selectType" ? (
|
||||
<SourceTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
) : selectedType === "formbricks" ? (
|
||||
/* Formbricks Survey Selector UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Label htmlFor="sourceName">{t("environments.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
placeholder={t("environments.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -419,15 +268,14 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
</div>
|
||||
</div>
|
||||
) : selectedType === "csv" ? (
|
||||
/* CSV Upload & S3 Integration UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Label htmlFor="sourceName">{t("environments.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
placeholder={t("environments.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -442,140 +290,37 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Other source types (webhook, email) - Mapping UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Label htmlFor="sourceName">{t("environments.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
placeholder={t("environments.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Webhook Listener UI */}
|
||||
{selectedType === "webhook" && !webhookReceived && (
|
||||
<div className="space-y-6">
|
||||
{/* Centered waiting indicator */}
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50 py-12">
|
||||
<span className="relative mb-4 flex h-16 w-16">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-slate-300 opacity-75"></span>
|
||||
<span className="relative inline-flex h-16 w-16 items-center justify-center rounded-full bg-slate-200">
|
||||
<WebhookIcon className="h-8 w-8 text-slate-600" />
|
||||
</span>
|
||||
</span>
|
||||
<p className="text-lg font-medium text-slate-700">Waiting for webhook...</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Send a request to the URL below</p>
|
||||
</div>
|
||||
|
||||
{/* cURL example at bottom */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-slate-700">Test with cURL</Label>
|
||||
<div className="relative">
|
||||
<pre className="overflow-auto rounded-lg border border-slate-300 bg-slate-900 p-3 text-xs text-slate-100">
|
||||
<code>{`curl -X POST "${webhookUrl || "..."}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`}</code>
|
||||
</pre>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
disabled={!webhookUrl}
|
||||
className="absolute right-2 top-2">
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="mr-1 h-3 w-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook received - show success + mapping UI */}
|
||||
{selectedType === "webhook" && webhookReceived && (
|
||||
<div className="space-y-4">
|
||||
{/* Success indicator */}
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-green-200 bg-green-50 py-6">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-500">
|
||||
<CheckIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-green-700">Webhook received!</p>
|
||||
<p className="mt-1 text-sm text-green-600">
|
||||
{sourceFields.length} fields detected. Map them below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI suggest mapping button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
{sourceFields.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
{t("environments.unify.suggest_mapping")}
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={selectedType}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Non-webhook types */}
|
||||
{selectedType !== "webhook" && (
|
||||
<>
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||
{getLoadButtonLabel()}
|
||||
</Button>
|
||||
{sourceFields.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSuggestMapping}
|
||||
className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={selectedType!}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={selectedType!}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -583,16 +328,16 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType || selectedType === "slack"}>
|
||||
<Button onClick={handleNextStep} disabled={!selectedType || isDisabledType(selectedType)}>
|
||||
{selectedType === "formbricks"
|
||||
? "Select questions"
|
||||
? t("environments.unify.select_questions")
|
||||
: selectedType === "csv"
|
||||
? "Configure import"
|
||||
: "Create mapping"}
|
||||
? t("environments.unify.configure_import")
|
||||
: t("environments.unify.create_mapping")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -605,7 +350,7 @@ export function CreateSourceModal({ open, onOpenChange, onCreateSource, surveys
|
||||
? !isCsvValid
|
||||
: !allRequiredMapped)
|
||||
}>
|
||||
Setup connection
|
||||
{t("environments.unify.setup_connection")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -39,6 +39,7 @@ export function CsvSourceUI({
|
||||
onSourceFieldsChange,
|
||||
onLoadSampleCSV,
|
||||
}: CsvSourceUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
|
||||
const [showMapping, setShowMapping] = useState(false);
|
||||
@@ -132,7 +133,7 @@ export function CsvSourceUI({
|
||||
setShowMapping(false);
|
||||
onSourceFieldsChange([]);
|
||||
}}>
|
||||
Change file
|
||||
{t("environments.unify.change_file")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -166,7 +167,7 @@ export function CsvSourceUI({
|
||||
</div>
|
||||
{csvPreview.length > 4 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||
Showing 3 of {csvPreview.length - 1} rows
|
||||
{t("environments.unify.showing_rows", { count: csvPreview.length - 1 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -188,7 +189,7 @@ export function CsvSourceUI({
|
||||
<div className="space-y-6">
|
||||
{/* Manual Upload Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Upload CSV File</h4>
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.upload_csv_file")}</h4>
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-file-upload"
|
||||
@@ -197,9 +198,10 @@ export function CsvSourceUI({
|
||||
onDrop={handleDrop}>
|
||||
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
<span className="font-semibold">{t("environments.unify.click_to_upload")}</span>{" "}
|
||||
{t("environments.unify.or_drag_and_drop")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">CSV files only</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("environments.unify.csv_files_only")}</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-file-upload"
|
||||
@@ -211,7 +213,7 @@ export function CsvSourceUI({
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
|
||||
Load sample CSV
|
||||
{t("environments.unify.load_sample_csv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,7 +221,7 @@ export function CsvSourceUI({
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
<span className="text-xs font-medium uppercase text-slate-400">or</span>
|
||||
<span className="text-xs font-medium uppercase text-slate-400">{t("environments.unify.or")}</span>
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
|
||||
@@ -227,27 +229,26 @@ export function CsvSourceUI({
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudIcon className="h-5 w-5 text-slate-500" />
|
||||
<h4 className="text-sm font-medium text-slate-700">S3 Bucket Integration</h4>
|
||||
<Badge text="Automated" type="gray" size="tiny" />
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{t("environments.unify.s3_bucket_integration")}
|
||||
</h4>
|
||||
<Badge text={t("environments.unify.automated")} type="gray" size="tiny" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<p className="mb-4 text-sm text-slate-600">
|
||||
Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15
|
||||
minutes.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-slate-600">{t("environments.unify.s3_bucket_description")}</p>
|
||||
|
||||
{/* S3 Path Display */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Drop zone path</Label>
|
||||
<Label className="text-xs">{t("environments.unify.drop_zone_path")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-slate-100 px-3 py-2 font-mono text-sm text-slate-700">
|
||||
{s3Path}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyS3Path}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{s3Copied ? "Copied!" : "Copy"}
|
||||
{s3Copied ? t("environments.unify.copied") : t("environments.unify.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,31 +256,35 @@ export function CsvSourceUI({
|
||||
{/* S3 Settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">AWS Region</Label>
|
||||
<Label className="text-xs">{t("environments.unify.aws_region")}</Label>
|
||||
<Select defaultValue="eu-central-1">
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
|
||||
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
|
||||
<SelectItem value="eu-central-1">EU (Frankfurt)</SelectItem>
|
||||
<SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
|
||||
<SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
|
||||
<SelectItem value="us-east-1">{t("environments.unify.region_us_east_1")}</SelectItem>
|
||||
<SelectItem value="us-west-2">{t("environments.unify.region_us_west_2")}</SelectItem>
|
||||
<SelectItem value="eu-central-1">
|
||||
{t("environments.unify.region_eu_central_1")}
|
||||
</SelectItem>
|
||||
<SelectItem value="eu-west-1">{t("environments.unify.region_eu_west_1")}</SelectItem>
|
||||
<SelectItem value="ap-southeast-1">
|
||||
{t("environments.unify.region_ap_southeast_1")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Processing interval</Label>
|
||||
<Label className="text-xs">{t("environments.unify.processing_interval")}</Label>
|
||||
<Select defaultValue="15">
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">Every 5 minutes</SelectItem>
|
||||
<SelectItem value="15">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="30">Every 30 minutes</SelectItem>
|
||||
<SelectItem value="60">Every hour</SelectItem>
|
||||
<SelectItem value="5">{t("environments.unify.every_5_minutes")}</SelectItem>
|
||||
<SelectItem value="15">{t("environments.unify.every_15_minutes")}</SelectItem>
|
||||
<SelectItem value="30">{t("environments.unify.every_30_minutes")}</SelectItem>
|
||||
<SelectItem value="60">{t("environments.unify.every_hour")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -288,9 +293,11 @@ export function CsvSourceUI({
|
||||
{/* Auto-sync toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-slate-900">Enable auto-sync</span>
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{t("environments.unify.enable_auto_sync")}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Automatically process new files dropped in the bucket
|
||||
{t("environments.unify.process_new_files_description")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={s3AutoSync} onCheckedChange={setS3AutoSync} />
|
||||
@@ -301,11 +308,13 @@ export function CsvSourceUI({
|
||||
<div className="flex items-start gap-2">
|
||||
<SettingsIcon className="mt-0.5 h-4 w-4 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800">IAM Configuration Required</p>
|
||||
<p className="text-sm font-medium text-amber-800">
|
||||
{t("environments.unify.iam_configuration_required")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
Add the Formbricks IAM role to your S3 bucket policy to enable access.{" "}
|
||||
{t("environments.unify.iam_setup_instructions")}{" "}
|
||||
<button type="button" className="font-medium underline hover:no-underline">
|
||||
View setup guide →
|
||||
{t("environments.unify.view_setup_guide")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -316,7 +325,7 @@ export function CsvSourceUI({
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
Test connection
|
||||
{t("environments.unify.test_connection")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FileSpreadsheetIcon,
|
||||
GlobeIcon,
|
||||
MailIcon,
|
||||
@@ -10,8 +8,8 @@ import {
|
||||
SparklesIcon,
|
||||
WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -26,36 +24,18 @@ import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
AI_SUGGESTED_MAPPINGS,
|
||||
EMAIL_SOURCE_FIELDS,
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
SAMPLE_WEBHOOK_PAYLOAD,
|
||||
TFieldMapping,
|
||||
TSourceConnection,
|
||||
TSourceField,
|
||||
TSourceType,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
import { parseCSVColumnsToFields, parsePayloadToFields } from "../utils";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
// Polling interval in milliseconds (3 seconds)
|
||||
const WEBHOOK_POLL_INTERVAL = 3000;
|
||||
|
||||
// Sample webhook payload for cURL example
|
||||
const SAMPLE_CURL_PAYLOAD = {
|
||||
timestamp: new Date().toISOString(),
|
||||
source_type: "webhook",
|
||||
field_id: "satisfaction_score",
|
||||
field_type: "rating",
|
||||
value_number: 4,
|
||||
user_id: "user_123",
|
||||
metadata: {
|
||||
source: "api",
|
||||
},
|
||||
};
|
||||
|
||||
interface EditSourceModalProps {
|
||||
source: TSourceConnection | null;
|
||||
open: boolean;
|
||||
@@ -67,7 +47,6 @@ interface EditSourceModalProps {
|
||||
) => void;
|
||||
onDeleteSource: (sourceId: string) => void;
|
||||
surveys: TUnifySurvey[];
|
||||
// For Formbricks connectors - the currently selected survey/elements
|
||||
initialSurveyId?: string | null;
|
||||
initialElementIds?: string[];
|
||||
}
|
||||
@@ -89,36 +68,23 @@ function getSourceIcon(type: TSourceType) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceTypeLabel(type: TSourceType) {
|
||||
function getSourceTypeLabelKey(type: TSourceType): string {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks Surveys";
|
||||
return "environments.unify.formbricks_surveys";
|
||||
case "webhook":
|
||||
return "Webhook";
|
||||
return "environments.unify.webhook";
|
||||
case "email":
|
||||
return "Email";
|
||||
return "environments.unify.email";
|
||||
case "csv":
|
||||
return "CSV Import";
|
||||
return "environments.unify.csv_import";
|
||||
case "slack":
|
||||
return "Slack Message";
|
||||
return "environments.unify.slack_message";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialSourceFields(type: TSourceType): TSourceField[] {
|
||||
switch (type) {
|
||||
case "webhook":
|
||||
return parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
case "email":
|
||||
return EMAIL_SOURCE_FIELDS;
|
||||
case "csv":
|
||||
return parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function EditSourceModal({
|
||||
source,
|
||||
open,
|
||||
@@ -129,123 +95,29 @@ export function EditSourceModal({
|
||||
initialSurveyId,
|
||||
initialElementIds = [],
|
||||
}: EditSourceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||
|
||||
// Formbricks-specific state
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
||||
|
||||
// Webhook listener state
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [webhookReceived, setWebhookReceived] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Permanent webhook URL using connector ID
|
||||
const webhookUrl = source
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/unify/webhook/${source.id}`
|
||||
: "";
|
||||
|
||||
// Poll for webhook payload using connector ID
|
||||
const pollForWebhook = useCallback(async () => {
|
||||
if (!source?.id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/unify/webhook/${source.id}`);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
if (data.payload) {
|
||||
// Parse the received payload into source fields
|
||||
const fields = parsePayloadToFields(data.payload);
|
||||
setSourceFields(fields);
|
||||
setWebhookReceived(true);
|
||||
setIsListening(false);
|
||||
toast.success("Webhook received! Fields loaded.");
|
||||
|
||||
// Stop polling
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 204 means no payload yet, keep polling
|
||||
} catch (error) {
|
||||
console.error("Error polling for webhook:", error);
|
||||
}
|
||||
}, [source?.id]);
|
||||
|
||||
// Start/stop polling based on listening state
|
||||
useEffect(() => {
|
||||
if (isListening && source?.id) {
|
||||
// Start polling
|
||||
pollingIntervalRef.current = setInterval(pollForWebhook, WEBHOOK_POLL_INTERVAL);
|
||||
// Also poll immediately
|
||||
pollForWebhook();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup polling on unmount or when listening stops
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isListening, source?.id, pollForWebhook]);
|
||||
|
||||
// Copy webhook URL to clipboard
|
||||
const handleCopyWebhookUrl = async () => {
|
||||
if (!webhookUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
setCopied(true);
|
||||
toast.success("Webhook URL copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (source) {
|
||||
setSourceName(source.name);
|
||||
setMappings(source.mappings);
|
||||
setDeriveFromAttachments(false);
|
||||
|
||||
// For Formbricks connectors, set the initial survey/element selection
|
||||
if (source.type === "formbricks") {
|
||||
setSelectedSurveyId(initialSurveyId ?? null);
|
||||
setSelectedElementIds(initialElementIds);
|
||||
setSourceFields(getInitialSourceFields(source.type));
|
||||
} else if (source.type === "webhook") {
|
||||
// Webhook: if we already have mappings, show them; otherwise show listening state
|
||||
if (source.mappings.length > 0) {
|
||||
// Build source fields from existing mapping source IDs so the mapping UI can display them
|
||||
const sourceFieldIds = new Set<string>();
|
||||
for (const m of source.mappings) {
|
||||
if (m.sourceFieldId) sourceFieldIds.add(m.sourceFieldId);
|
||||
}
|
||||
const fieldsFromMappings = Array.from(sourceFieldIds).map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: "string",
|
||||
sampleValue: "",
|
||||
}));
|
||||
setSourceFields(fieldsFromMappings);
|
||||
setWebhookReceived(true);
|
||||
setIsListening(false);
|
||||
} else {
|
||||
setSourceFields([]);
|
||||
setIsListening(true);
|
||||
setWebhookReceived(false);
|
||||
}
|
||||
setSourceFields([]);
|
||||
} else if (source.type === "csv") {
|
||||
setSourceFields(parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS));
|
||||
} else {
|
||||
setSourceFields(getInitialSourceFields(source.type));
|
||||
setSourceFields([]);
|
||||
}
|
||||
}
|
||||
}, [source, initialSurveyId, initialElementIds]);
|
||||
@@ -255,17 +127,8 @@ export function EditSourceModal({
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setShowDeleteConfirm(false);
|
||||
setDeriveFromAttachments(false);
|
||||
setSelectedSurveyId(null);
|
||||
setSelectedElementIds([]);
|
||||
// Reset webhook state
|
||||
setIsListening(false);
|
||||
setWebhookReceived(false);
|
||||
setCopied(false);
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -275,7 +138,6 @@ export function EditSourceModal({
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
// Formbricks handlers
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
@@ -307,7 +169,6 @@ export function EditSourceModal({
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// For Formbricks, pass the survey/element selection
|
||||
if (source.type === "formbricks") {
|
||||
onUpdateSource(updatedSource, selectedSurveyId ?? undefined, selectedElementIds);
|
||||
} else {
|
||||
@@ -322,21 +183,6 @@ export function EditSourceModal({
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (!source) return;
|
||||
let fields: TSourceField[];
|
||||
if (source.type === "webhook") {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
} else if (source.type === "email") {
|
||||
fields = EMAIL_SOURCE_FIELDS;
|
||||
} else if (source.type === "csv") {
|
||||
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
} else {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
}
|
||||
setSourceFields(fields);
|
||||
};
|
||||
|
||||
const handleSuggestMapping = () => {
|
||||
if (!source) return;
|
||||
const suggestions = AI_SUGGESTED_MAPPINGS[source.type];
|
||||
@@ -372,27 +218,14 @@ export function EditSourceModal({
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const getLoadButtonLabel = () => {
|
||||
switch (source?.type) {
|
||||
case "webhook":
|
||||
return "Simulate webhook";
|
||||
case "email":
|
||||
return "Load email fields";
|
||||
case "csv":
|
||||
return "Load sample CSV";
|
||||
default:
|
||||
return "Load sample";
|
||||
}
|
||||
};
|
||||
|
||||
if (!source) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Source Connection</DialogTitle>
|
||||
<DialogDescription>Update the mapping configuration for this source.</DialogDescription>
|
||||
<DialogTitle>{t("environments.unify.edit_source_connection")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.unify.update_mapping_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
@@ -400,24 +233,25 @@ export function EditSourceModal({
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getSourceIcon(source.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{getSourceTypeLabel(source.type)}</p>
|
||||
<p className="text-xs text-slate-500">Source type cannot be changed</p>
|
||||
<p className="text-sm font-medium text-slate-900">{t(getSourceTypeLabelKey(source.type))}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.unify.source_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editSourceName">Source Name</Label>
|
||||
<Label htmlFor="editSourceName">{t("environments.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="editSourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
placeholder={t("environments.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{source.type === "formbricks" ? (
|
||||
/* Formbricks Survey Selector UI */
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
surveys={surveys}
|
||||
@@ -430,141 +264,29 @@ export function EditSourceModal({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Other source types - Mapping UI */
|
||||
<>
|
||||
{/* Webhook Listener UI - Waiting state */}
|
||||
{source.type === "webhook" && !webhookReceived && (
|
||||
<div className="space-y-4">
|
||||
{/* Permanent Webhook URL */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||
<WebhookIcon className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-blue-900">Your Webhook URL</p>
|
||||
<p className="mt-0.5 text-xs text-blue-700">
|
||||
This is your permanent webhook endpoint. Use it in your integrations.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-white px-2 py-1 text-xs text-blue-800">
|
||||
{webhookUrl || "Loading..."}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
disabled={!webhookUrl}
|
||||
className="shrink-0 border-blue-300 text-blue-700 hover:bg-blue-100">
|
||||
{copied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Centered waiting indicator */}
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50 py-8">
|
||||
<span className="relative mb-3 flex h-12 w-12">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-slate-300 opacity-75"></span>
|
||||
<span className="relative inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-200">
|
||||
<WebhookIcon className="h-6 w-6 text-slate-600" />
|
||||
</span>
|
||||
</span>
|
||||
<p className="text-sm font-medium text-slate-700">Listening for test payload...</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Send a request to update field mappings</p>
|
||||
</div>
|
||||
|
||||
{/* cURL example */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-slate-700">Test with cURL</Label>
|
||||
<div className="relative">
|
||||
<pre className="overflow-auto rounded-lg border border-slate-300 bg-slate-900 p-3 text-xs text-slate-100">
|
||||
<code>{`curl -X POST "${webhookUrl || "..."}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(SAMPLE_CURL_PAYLOAD, null, 2)}'`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook configured - show mapping UI */}
|
||||
{source.type === "webhook" && webhookReceived && (
|
||||
<div className="space-y-4">
|
||||
{/* Webhook URL + copy (when already configured) */}
|
||||
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<span className="text-xs font-medium text-slate-600">Webhook URL:</span>
|
||||
<code className="min-w-0 flex-1 truncate text-xs text-slate-700">
|
||||
{webhookUrl || "..."}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
disabled={!webhookUrl}
|
||||
className="h-7 shrink-0 px-2">
|
||||
{copied ? <CheckIcon className="h-3 w-3" /> : <CopyIcon className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* AI suggest mapping button */}
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
{sourceFields.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
{t("environments.unify.suggest_mapping")}
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={source.type}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Non-webhook types */}
|
||||
{source.type !== "webhook" && (
|
||||
<>
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||
{getLoadButtonLabel()}
|
||||
</Button>
|
||||
{sourceFields.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={source.type}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={source.type}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -573,17 +295,17 @@ export function EditSourceModal({
|
||||
<div>
|
||||
{showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600">Are you sure?</span>
|
||||
<span className="text-sm text-red-600">{t("environments.unify.are_you_sure")}</span>
|
||||
<Button variant="destructive" size="sm" onClick={handleDeleteSource}>
|
||||
Yes, delete
|
||||
{t("environments.unify.yes_delete")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
|
||||
Delete source
|
||||
{t("environments.unify.delete_source")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -593,7 +315,7 @@ export function EditSourceModal({
|
||||
!sourceName.trim() ||
|
||||
(source.type === "formbricks" && (!selectedSurveyId || selectedElementIds.length === 0))
|
||||
}>
|
||||
Save changes
|
||||
{t("environments.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -38,21 +38,6 @@ function getElementIcon(type: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: TUnifySurvey["status"]) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge text="Active" type="success" size="tiny" />;
|
||||
case "paused":
|
||||
return <Badge text="Paused" type="warning" size="tiny" />;
|
||||
case "draft":
|
||||
return <Badge text="Draft" type="gray" size="tiny" />;
|
||||
case "completed":
|
||||
return <Badge text="Completed" type="gray" size="tiny" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function FormbricksSurveySelector({
|
||||
surveys,
|
||||
selectedSurveyId,
|
||||
@@ -69,10 +54,8 @@ export function FormbricksSurveySelector({
|
||||
|
||||
const handleSurveyClick = (survey: TUnifySurvey) => {
|
||||
if (selectedSurveyId === survey.id) {
|
||||
// Toggle expand/collapse if already selected
|
||||
setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id);
|
||||
} else {
|
||||
// Select the survey and expand it
|
||||
onSurveySelect(survey.id);
|
||||
onDeselectAllElements();
|
||||
setExpandedSurveyId(survey.id);
|
||||
@@ -81,15 +64,30 @@ export function FormbricksSurveySelector({
|
||||
|
||||
const allElementsSelected = selectedSurvey && selectedElementIds.length === selectedSurvey.elements.length;
|
||||
|
||||
const getStatusBadge = (status: TUnifySurvey["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge text={t("environments.unify.status_active")} type="success" size="tiny" />;
|
||||
case "paused":
|
||||
return <Badge text={t("environments.unify.status_paused")} type="warning" size="tiny" />;
|
||||
case "draft":
|
||||
return <Badge text={t("environments.unify.status_draft")} type="gray" size="tiny" />;
|
||||
case "completed":
|
||||
return <Badge text={t("environments.unify.status_completed")} type="gray" size="tiny" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left: Survey List */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Select Survey</h4>
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.select_survey")}</h4>
|
||||
<div className="space-y-2">
|
||||
{surveys.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">No surveys found in this environment</p>
|
||||
<p className="text-sm text-slate-500">{t("environments.unify.no_surveys_found")}</p>
|
||||
</div>
|
||||
) : (
|
||||
surveys.map((survey) => {
|
||||
@@ -118,7 +116,9 @@ export function FormbricksSurveySelector({
|
||||
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
|
||||
{getStatusBadge(survey.status)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">{survey.elements.length} elements</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.unify.n_elements", { count: survey.elements.length })}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
|
||||
</button>
|
||||
@@ -132,7 +132,7 @@ export function FormbricksSurveySelector({
|
||||
{/* Right: Element Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Select Elements</h4>
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.select_elements")}</h4>
|
||||
{selectedSurvey && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -140,18 +140,22 @@ export function FormbricksSurveySelector({
|
||||
allElementsSelected ? onDeselectAllElements() : onSelectAllElements(selectedSurvey.id)
|
||||
}
|
||||
className="text-xs text-slate-500 hover:text-slate-700">
|
||||
{allElementsSelected ? "Deselect all" : "Select all"}
|
||||
{allElementsSelected
|
||||
? t("environments.unify.deselect_all")
|
||||
: t("environments.unify.select_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedSurvey ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">Select a survey to see its elements</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.unify.select_a_survey_to_see_elements")}
|
||||
</p>
|
||||
</div>
|
||||
) : selectedSurvey.elements.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">This survey has no question elements</p>
|
||||
<p className="text-sm text-slate-500">{t("environments.unify.survey_has_no_elements")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -183,7 +187,8 @@ export function FormbricksSurveySelector({
|
||||
</span>
|
||||
{element.required && (
|
||||
<span className="text-xs text-red-500">
|
||||
<CircleIcon className="inline h-1.5 w-1.5 fill-current" /> Required
|
||||
<CircleIcon className="inline h-1.5 w-1.5 fill-current" />{" "}
|
||||
{t("environments.unify.required")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,11 +199,15 @@ export function FormbricksSurveySelector({
|
||||
|
||||
{selectedElementIds.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<strong>{selectedElementIds.length}</strong> element
|
||||
{selectedElementIds.length !== 1 ? "s" : ""} selected. Each response to these elements will
|
||||
create a FeedbackRecord in the Hub.
|
||||
</p>
|
||||
<p
|
||||
className="text-xs text-blue-700"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
selectedElementIds.length === 1
|
||||
? t("environments.unify.element_selected", { count: selectedElementIds.length })
|
||||
: t("environments.unify.elements_selected", { count: selectedElementIds.length }),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -72,6 +73,7 @@ export function DroppableTargetField({
|
||||
onStaticValueChange,
|
||||
isOver,
|
||||
}: DroppableTargetFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
@@ -95,11 +97,11 @@ export function DroppableTargetField({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">(enum)</span>
|
||||
<span className="text-xs text-slate-400">({t("common.enum", "enum")})</span>
|
||||
</div>
|
||||
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||
<SelectTrigger className="h-8 w-full bg-white">
|
||||
<SelectValue placeholder="Select a value..." />
|
||||
<SelectValue placeholder={t("environments.unify.select_a_value")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues.map((value) => (
|
||||
@@ -202,13 +204,13 @@ export function DroppableTargetField({
|
||||
{/* Show example values as quick select OR drop zone */}
|
||||
{!hasMapping && !isEditingStatic && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">Drop field or</span>
|
||||
<span className="text-xs text-slate-400">{t("environments.unify.drop_field_or")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingStatic(true)}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
set value
|
||||
{t("environments.unify.set_value")}
|
||||
</button>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
@@ -233,7 +235,7 @@ export function DroppableTargetField({
|
||||
|
||||
// Helper to get display label for static values
|
||||
const getStaticValueLabel = (value: string) => {
|
||||
if (value === "$now") return "Feedback date";
|
||||
if (value === "$now") return t("environments.unify.feedback_date");
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -282,7 +284,7 @@ export function DroppableTargetField({
|
||||
{/* Show drop zone with preset options */}
|
||||
{!hasDefaultMapping && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">Drop a field here</span>
|
||||
<span className="text-xs text-slate-400">{t("environments.unify.drop_a_field_here")}</span>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
|
||||
import { CopyIcon, MailIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField, TSourceType } from "../types";
|
||||
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
|
||||
|
||||
@@ -13,36 +11,11 @@ interface MappingUIProps {
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
sourceType: TSourceType;
|
||||
deriveFromAttachments?: boolean;
|
||||
onDeriveFromAttachmentsChange?: (value: boolean) => void;
|
||||
emailInboxId?: string;
|
||||
}
|
||||
|
||||
export function MappingUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
sourceType,
|
||||
deriveFromAttachments = false,
|
||||
onDeriveFromAttachmentsChange,
|
||||
emailInboxId,
|
||||
}: MappingUIProps) {
|
||||
export function MappingUI({ sourceFields, mappings, onMappingsChange, sourceType }: MappingUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [emailCopied, setEmailCopied] = useState(false);
|
||||
|
||||
// Generate a stable random email ID if not provided
|
||||
const generatedEmailId = useMemo(() => {
|
||||
if (emailInboxId) return emailInboxId;
|
||||
return `fb-${Math.random().toString(36).substring(2, 8)}`;
|
||||
}, [emailInboxId]);
|
||||
|
||||
const inboxEmail = `${generatedEmailId}@inbox.formbricks.com`;
|
||||
|
||||
const handleCopyEmail = () => {
|
||||
navigator.clipboard.writeText(inboxEmail);
|
||||
setEmailCopied(true);
|
||||
setTimeout(() => setEmailCopied(false), 2000);
|
||||
};
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
|
||||
@@ -60,14 +33,11 @@ export function MappingUI({
|
||||
const sourceFieldId = active.id as string;
|
||||
const targetFieldId = over.id as string;
|
||||
|
||||
// Check if this target already has a mapping
|
||||
const existingMapping = mappings.find((m) => m.targetFieldId === targetFieldId);
|
||||
if (existingMapping) {
|
||||
// Remove the existing mapping first
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
} else {
|
||||
// Remove any existing mapping for this source field
|
||||
const newMappings = mappings.filter((m) => m.sourceFieldId !== sourceFieldId);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
}
|
||||
@@ -78,9 +48,7 @@ export function MappingUI({
|
||||
};
|
||||
|
||||
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
|
||||
// Remove any existing mapping for this target field
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
// Add new static value mapping
|
||||
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
|
||||
};
|
||||
|
||||
@@ -99,48 +67,15 @@ export function MappingUI({
|
||||
|
||||
const getSourceTypeLabel = () => {
|
||||
switch (sourceType) {
|
||||
case "webhook":
|
||||
return "Webhook Payload";
|
||||
case "email":
|
||||
return "Email Fields";
|
||||
case "csv":
|
||||
return "CSV Columns";
|
||||
return t("environments.unify.csv_columns");
|
||||
default:
|
||||
return "Source Fields";
|
||||
return t("environments.unify.source_fields");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{/* Email inbox address display */}
|
||||
{sourceType === "email" && (
|
||||
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<MailIcon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-900">Your feedback inbox</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
Forward emails to this address to capture feedback automatically
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="rounded bg-white px-2 py-1 font-mono text-sm text-blue-700">
|
||||
{inboxEmail}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyEmail}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100">
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
{emailCopied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Source Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
@@ -149,13 +84,9 @@ export function MappingUI({
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">
|
||||
{sourceType === "webhook"
|
||||
? "Click 'Simulate webhook' to load sample fields"
|
||||
: sourceType === "email"
|
||||
? "Click 'Load email fields' to see available fields"
|
||||
: sourceType === "csv"
|
||||
? "Click 'Load sample CSV' to see columns"
|
||||
: "No source fields loaded yet"}
|
||||
{sourceType === "csv"
|
||||
? t("environments.unify.click_load_sample_csv")
|
||||
: t("environments.unify.no_source_fields_loaded")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -165,31 +96,19 @@ export function MappingUI({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email-specific options */}
|
||||
{sourceType === "email" && onDeriveFromAttachmentsChange && (
|
||||
<div className="mt-4 flex items-center justify-between rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-900">Derive context from attachments</span>
|
||||
<Badge text="AI" type="gray" size="tiny" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
Extract additional context from email attachments using AI
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={deriveFromAttachments} onCheckedChange={onDeriveFromAttachmentsChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Hub Feedback Record Fields</h4>
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{t("environments.unify.hub_feedback_record_fields")}
|
||||
</h4>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Required</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("environments.unify.required")}
|
||||
</p>
|
||||
{requiredFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
@@ -204,7 +123,9 @@ export function MappingUI({
|
||||
|
||||
{/* Optional Fields */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Optional</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("environments.unify.optional")}
|
||||
</p>
|
||||
{optionalFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SOURCE_OPTIONS, TSourceType } from "../types";
|
||||
import { TSourceType, getSourceOptions } from "../types";
|
||||
|
||||
interface SourceTypeSelectorProps {
|
||||
selectedType: TSourceType | null;
|
||||
@@ -9,11 +10,14 @@ interface SourceTypeSelectorProps {
|
||||
}
|
||||
|
||||
export function SourceTypeSelector({ selectedType, onSelectType }: SourceTypeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const sourceOptions = getSourceOptions(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600">Select the type of feedback source you want to connect:</p>
|
||||
<p className="text-sm text-slate-600">{t("environments.unify.select_source_type_prompt")}</p>
|
||||
<div className="space-y-2">
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
{sourceOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings, TFormbricksConnector } from "@formbricks/types/connector";
|
||||
import {
|
||||
createConnectorAction,
|
||||
@@ -25,23 +26,12 @@ interface SourcesSectionProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
// Transform connector from database to TSourceConnection for UI
|
||||
function connectorToSourceConnection(connector: TConnectorWithMappings): TSourceConnection {
|
||||
// For webhook (and other field-mapping connectors), include field mappings
|
||||
const mappings =
|
||||
connector.type === "webhook" && "fieldMappings" in connector && connector.fieldMappings?.length
|
||||
? connector.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
type: connector.type as TSourceConnection["type"],
|
||||
mappings,
|
||||
mappings: [],
|
||||
createdAt: connector.createdAt,
|
||||
updatedAt: connector.updatedAt,
|
||||
};
|
||||
@@ -71,6 +61,7 @@ function getFormbricksMappingData(connector: TConnectorWithMappings): {
|
||||
}
|
||||
|
||||
export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sources, setSources] = useState<TSourceConnection[]>([]);
|
||||
const [connectorsMap, setConnectorsMap] = useState<Map<string, TConnectorWithMappings>>(new Map());
|
||||
const [surveys, setSurveys] = useState<TUnifySurvey[]>([]);
|
||||
@@ -103,7 +94,7 @@ export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data:", error);
|
||||
toast.error("Failed to load data");
|
||||
toast.error(t("environments.unify.failed_to_load_data"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -129,19 +120,19 @@ export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error("Failed to create connector");
|
||||
toast.error(t("environments.unify.failed_to_create_connector"));
|
||||
return;
|
||||
}
|
||||
|
||||
const connectorResult = result.data;
|
||||
if ("error" in connectorResult && connectorResult.error) {
|
||||
toast.error(connectorResult.error.message || "Failed to create connector");
|
||||
toast.error(connectorResult.error.message || t("environments.unify.failed_to_create_connector"));
|
||||
return;
|
||||
}
|
||||
|
||||
const connector = "data" in connectorResult ? connectorResult.data : connectorResult;
|
||||
if (!connector || !connector.id) {
|
||||
toast.error("Failed to create connector - invalid response");
|
||||
toast.error(t("environments.unify.failed_to_create_connector"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,10 +176,10 @@ export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
|
||||
// Refresh the list
|
||||
await fetchData();
|
||||
toast.success("Connector created successfully");
|
||||
toast.success(t("environments.unify.connector_created_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to create connector:", error);
|
||||
toast.error("Failed to create connector");
|
||||
toast.error(t("environments.unify.failed_to_create_connector"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,7 +198,7 @@ export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error("Failed to update connector");
|
||||
toast.error(t("environments.unify.failed_to_update_connector"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,10 +241,10 @@ export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
|
||||
// Refresh the list
|
||||
await fetchData();
|
||||
toast.success("Connector updated successfully");
|
||||
toast.success(t("environments.unify.connector_updated_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to update connector:", error);
|
||||
toast.error("Failed to update connector");
|
||||
toast.error(t("environments.unify.failed_to_update_connector"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,16 +255,16 @@ export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error("Failed to delete connector");
|
||||
toast.error(t("environments.unify.failed_to_delete_connector"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await fetchData();
|
||||
toast.success("Connector deleted successfully");
|
||||
toast.success(t("environments.unify.connector_deleted_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete connector:", error);
|
||||
toast.error("Failed to delete connector");
|
||||
toast.error(t("environments.unify.failed_to_delete_connector"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -284,7 +275,7 @@ export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle="Unify Feedback"
|
||||
pageTitle={t("environments.unify.unify_feedback")}
|
||||
cta={
|
||||
<CreateSourceModal
|
||||
open={isCreateModalOpen}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { FileSpreadsheetIcon, GlobeIcon, MailIcon, MessageSquareIcon, WebhookIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSourceType } from "../types";
|
||||
|
||||
interface SourcesTableDataRowProps {
|
||||
@@ -30,23 +31,6 @@ function getSourceIcon(type: TSourceType) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceTypeLabel(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks";
|
||||
case "webhook":
|
||||
return "Webhook";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "csv":
|
||||
return "CSV";
|
||||
case "slack":
|
||||
return "Slack";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export function SourcesTableDataRow({
|
||||
id,
|
||||
name,
|
||||
@@ -55,6 +39,25 @@ export function SourcesTableDataRow({
|
||||
createdAt,
|
||||
onClick,
|
||||
}: SourcesTableDataRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getSourceTypeLabel = (sourceType: TSourceType) => {
|
||||
switch (sourceType) {
|
||||
case "formbricks":
|
||||
return t("environments.unify.formbricks_surveys");
|
||||
case "webhook":
|
||||
return t("environments.unify.webhook");
|
||||
case "email":
|
||||
return t("environments.unify.email");
|
||||
case "csv":
|
||||
return t("environments.unify.csv_import");
|
||||
case "slack":
|
||||
return t("environments.unify.slack_message");
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
@@ -75,7 +78,7 @@ export function SourcesTableDataRow({
|
||||
<span className="truncate font-medium text-slate-900">{name}</span>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
|
||||
{mappingsCount} {mappingsCount === 1 ? "field" : "fields"}
|
||||
{mappingsCount} {mappingsCount === 1 ? t("environments.unify.field") : t("environments.unify.fields")}
|
||||
</div>
|
||||
<div className="col-span-3 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
|
||||
{formatDistanceToNow(createdAt, { addSuffix: true })}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSourceConnection } from "../types";
|
||||
import { SourcesTableDataRow } from "./sources-table-data-row";
|
||||
|
||||
@@ -11,13 +12,15 @@ interface SourcesTableProps {
|
||||
}
|
||||
|
||||
export function SourcesTable({ sources, onSourceClick, isLoading = false }: SourcesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2 pl-6">Type</div>
|
||||
<div className="col-span-5">Name</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">Mappings</div>
|
||||
<div className="col-span-3 hidden pr-6 text-right sm:block">Created</div>
|
||||
<div className="col-span-2 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.mappings")}</div>
|
||||
<div className="col-span-3 hidden pr-6 text-right sm:block">{t("common.created")}</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
@@ -25,7 +28,7 @@ export function SourcesTable({ sources, onSourceClick, isLoading = false }: Sour
|
||||
</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">No sources connected yet. Add a source to get started.</p>
|
||||
<p className="text-sm text-slate-500">{t("environments.unify.no_sources_connected")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
|
||||
@@ -1,48 +1,51 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
|
||||
// Source types for the feedback source connections
|
||||
export type TSourceType = "formbricks" | "webhook" | "email" | "csv" | "slack";
|
||||
|
||||
export const SOURCE_OPTIONS: {
|
||||
export interface TSourceOption {
|
||||
id: TSourceType;
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
badge?: { text: string; type: "success" | "gray" | "warning" };
|
||||
}[] = [
|
||||
}
|
||||
|
||||
export const getSourceOptions = (t: TFunction): TSourceOption[] => [
|
||||
{
|
||||
id: "formbricks",
|
||||
name: "Formbricks Surveys",
|
||||
description: "Connect feedback from your Formbricks surveys",
|
||||
name: t("environments.unify.formbricks_surveys"),
|
||||
description: t("environments.unify.source_connect_formbricks_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "webhook",
|
||||
name: "Webhook",
|
||||
description: "Receive feedback via webhook with custom mapping",
|
||||
name: t("environments.unify.webhook"),
|
||||
description: t("environments.unify.source_connect_webhook_description"),
|
||||
disabled: true,
|
||||
badge: { text: "Coming soon", type: "gray" },
|
||||
badge: { text: t("environments.unify.coming_soon"), type: "gray" },
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
name: "Email",
|
||||
description: "Import feedback from email with custom mapping",
|
||||
name: t("environments.unify.email"),
|
||||
description: t("environments.unify.source_connect_email_description"),
|
||||
disabled: true,
|
||||
badge: { text: "Coming soon", type: "gray" },
|
||||
badge: { text: t("environments.unify.coming_soon"), type: "gray" },
|
||||
},
|
||||
{
|
||||
id: "csv",
|
||||
name: "CSV Import",
|
||||
description: "Import feedback from CSV files",
|
||||
name: t("environments.unify.csv_import"),
|
||||
description: t("environments.unify.source_connect_csv_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack Message",
|
||||
description: "Connect feedback from Slack channels",
|
||||
name: t("environments.unify.slack_message"),
|
||||
description: t("environments.unify.source_connect_slack_description"),
|
||||
disabled: true,
|
||||
badge: { text: "Coming soon", type: "gray" },
|
||||
badge: { text: t("environments.unify.coming_soon"), type: "gray" },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -226,69 +229,18 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Sample data for connector setup UIs
|
||||
export const SAMPLE_WEBHOOK_PAYLOAD = {
|
||||
id: "resp_12345",
|
||||
timestamp: "2024-01-15T10:30:00Z",
|
||||
survey_id: "survey_abc",
|
||||
survey_name: "Product Feedback Survey",
|
||||
question_id: "q1",
|
||||
question_text: "How satisfied are you with our product?",
|
||||
answer_type: "rating",
|
||||
answer_value: 4,
|
||||
user_id: "user_xyz",
|
||||
metadata: {
|
||||
device: "mobile",
|
||||
browser: "Safari",
|
||||
},
|
||||
};
|
||||
|
||||
export const EMAIL_SOURCE_FIELDS: TSourceField[] = [
|
||||
{ id: "subject", name: "Subject", type: "string", sampleValue: "Feature Request: Dark Mode" },
|
||||
{
|
||||
id: "body",
|
||||
name: "Body (Text)",
|
||||
type: "string",
|
||||
sampleValue: "I would love to see a dark mode option...",
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
|
||||
|
||||
// AI suggested mappings per source type
|
||||
export const AI_SUGGESTED_MAPPINGS: Record<
|
||||
TSourceType,
|
||||
{
|
||||
fieldMappings: Record<string, string>;
|
||||
staticValues: Record<string, string>;
|
||||
}
|
||||
export const AI_SUGGESTED_MAPPINGS: Partial<
|
||||
Record<
|
||||
TSourceType,
|
||||
{
|
||||
fieldMappings: Record<string, string>;
|
||||
staticValues: Record<string, string>;
|
||||
}
|
||||
>
|
||||
> = {
|
||||
webhook: {
|
||||
fieldMappings: {
|
||||
timestamp: "collected_at",
|
||||
survey_id: "source_id",
|
||||
survey_name: "source_name",
|
||||
question_id: "field_id",
|
||||
question_text: "field_label",
|
||||
answer_value: "value_number",
|
||||
user_id: "user_identifier",
|
||||
},
|
||||
staticValues: {
|
||||
source_type: "survey",
|
||||
field_type: "rating",
|
||||
},
|
||||
},
|
||||
email: {
|
||||
fieldMappings: {
|
||||
subject: "field_label",
|
||||
body: "value_text",
|
||||
},
|
||||
staticValues: {
|
||||
collected_at: "$now",
|
||||
source_type: "email",
|
||||
field_type: "text",
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
fieldMappings: {
|
||||
timestamp: "collected_at",
|
||||
@@ -308,13 +260,6 @@ export const AI_SUGGESTED_MAPPINGS: Record<
|
||||
source_type: "survey",
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
fieldMappings: {},
|
||||
staticValues: {
|
||||
source_type: "support",
|
||||
field_type: "text",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Modal step types
|
||||
|
||||
@@ -33,64 +33,6 @@ export const elementTypeToHubFieldType = (type: TSurveyElementTypeEnum): THubFie
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePayloadToFields = (payload: Record<string, unknown>): TSourceField[] => {
|
||||
const fields: TSourceField[] = [];
|
||||
|
||||
const extractFields = (obj: Record<string, unknown>, prefix = ""): void => {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fieldId = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
fields.push({ id: fieldId, name: fieldId, type: "string", sampleValue: String(value) });
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
fields.push({ id: fieldId, name: fieldId, type: "array", sampleValue: "[]" });
|
||||
} else {
|
||||
const maxItems = Math.min(value.length, 3);
|
||||
for (let i = 0; i < maxItems; i++) {
|
||||
const item = value[i];
|
||||
const itemPrefix = `${fieldId}[${i}]`;
|
||||
|
||||
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
||||
extractFields(item as Record<string, unknown>, itemPrefix);
|
||||
} else if (Array.isArray(item)) {
|
||||
fields.push({
|
||||
id: itemPrefix,
|
||||
name: itemPrefix,
|
||||
type: "array",
|
||||
sampleValue: `[${item.length} items]`,
|
||||
});
|
||||
} else {
|
||||
let type = "string";
|
||||
if (typeof item === "number") type = "number";
|
||||
if (typeof item === "boolean") type = "boolean";
|
||||
fields.push({ id: itemPrefix, name: itemPrefix, type, sampleValue: String(item) });
|
||||
}
|
||||
}
|
||||
if (value.length > 3) {
|
||||
fields.push({
|
||||
id: `${fieldId}[...]`,
|
||||
name: `${fieldId}[...]`,
|
||||
type: "info",
|
||||
sampleValue: `+${value.length - 3} more items`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
extractFields(value as Record<string, unknown>, fieldId);
|
||||
} else {
|
||||
let type = "string";
|
||||
if (typeof value === "number") type = "number";
|
||||
if (typeof value === "boolean") type = "boolean";
|
||||
fields.push({ id: fieldId, name: fieldId, type, sampleValue: String(value) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extractFields(payload);
|
||||
return fields;
|
||||
};
|
||||
|
||||
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
|
||||
return columns.split(",").map((col) => {
|
||||
const trimmed = col.trim();
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getPayload, storePayload } from "@/lib/connector/webhook-listener-store";
|
||||
|
||||
// Maximum payload size in bytes (100KB)
|
||||
const MAX_PAYLOAD_SIZE = 100 * 1024;
|
||||
|
||||
/**
|
||||
* POST /api/unify/webhook-listener/[sessionId]
|
||||
* Receive an incoming webhook payload for testing
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { sessionId } = await props.params;
|
||||
|
||||
if (!sessionId || sessionId.length < 10) {
|
||||
return NextResponse.json({ error: "Invalid session ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check content length
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_PAYLOAD_SIZE) {
|
||||
return NextResponse.json({ error: "Payload too large. Maximum size is 100KB." }, { status: 413 });
|
||||
}
|
||||
|
||||
// Parse the JSON payload
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate payload is an object
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return NextResponse.json({ error: "Payload must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Store the payload
|
||||
const stored = storePayload(sessionId, payload);
|
||||
if (!stored) {
|
||||
return NextResponse.json({ error: "Failed to store payload. It may be too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
logger.info({ sessionId }, "Webhook payload received for session");
|
||||
|
||||
return NextResponse.json({ success: true, message: "Webhook received successfully" }, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, "Error processing webhook payload");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/unify/webhook-listener/[sessionId]
|
||||
* Poll for a received webhook payload
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
props: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { sessionId } = await props.params;
|
||||
|
||||
if (!sessionId || sessionId.length < 10) {
|
||||
return NextResponse.json({ error: "Invalid session ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the payload (and clear it from the store)
|
||||
const payload = getPayload(sessionId, true);
|
||||
|
||||
if (!payload) {
|
||||
// No payload received yet - return 204 No Content
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
// Return the payload
|
||||
return NextResponse.json({ payload }, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, "Error retrieving webhook payload");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/unify/webhook-listener/[sessionId]
|
||||
* Handle CORS preflight requests
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TCreateFeedbackRecordInput, createFeedbackRecordsBatch } from "@/lib/connector/hub-client";
|
||||
import { getPayload, storePayload } from "@/lib/connector/webhook-listener-store";
|
||||
|
||||
// Maximum payload size in bytes (100KB)
|
||||
const MAX_PAYLOAD_SIZE = 100 * 1024;
|
||||
|
||||
/**
|
||||
* POST /api/unify/webhook/[connectorId]
|
||||
*
|
||||
* Receive incoming webhook payloads for a connector.
|
||||
* - If connector has no field mappings yet: Store payload for setup UI
|
||||
* - If connector has field mappings: Process and send to Hub
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ connectorId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { connectorId } = await props.params;
|
||||
|
||||
if (!connectorId) {
|
||||
return NextResponse.json({ error: "Connector ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check content length
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_PAYLOAD_SIZE) {
|
||||
return NextResponse.json({ error: "Payload too large. Maximum size is 100KB." }, { status: 413 });
|
||||
}
|
||||
|
||||
// Parse the JSON payload
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate payload is an object
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return NextResponse.json({ error: "Payload must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch connector with its field mappings
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: { id: connectorId },
|
||||
include: {
|
||||
fieldMappings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!connector) {
|
||||
return NextResponse.json({ error: "Connector not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connector.type !== "webhook") {
|
||||
return NextResponse.json({ error: "This endpoint is only for webhook connectors" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (connector.status !== "active") {
|
||||
return NextResponse.json({ error: "Connector is not active" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if connector has field mappings configured
|
||||
const hasMappings = connector.fieldMappings && connector.fieldMappings.length > 0;
|
||||
|
||||
if (!hasMappings) {
|
||||
// Setup phase: Store payload for UI to fetch
|
||||
const stored = storePayload(connectorId, payload);
|
||||
if (!stored) {
|
||||
return NextResponse.json({ error: "Failed to store payload. It may be too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
logger.info({ connectorId }, "Webhook payload stored for setup");
|
||||
return NextResponse.json(
|
||||
{ success: true, message: "Payload received for setup", mode: "setup" },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Production phase: Transform and send to Hub
|
||||
const feedbackRecords: TCreateFeedbackRecordInput[] = [];
|
||||
|
||||
// Build a single feedback record from the payload using field mappings
|
||||
const record: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of connector.fieldMappings) {
|
||||
let value: unknown;
|
||||
|
||||
if (mapping.staticValue) {
|
||||
// Use static value
|
||||
value = mapping.staticValue;
|
||||
// Handle special static values
|
||||
if (value === "$now") {
|
||||
value = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
// Get value from payload using dot notation path
|
||||
value = getNestedValue(payload, mapping.sourceFieldId);
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
record[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure required fields have defaults
|
||||
if (!record.source_type) {
|
||||
record.source_type = "webhook";
|
||||
}
|
||||
if (!record.collected_at) {
|
||||
record.collected_at = new Date().toISOString();
|
||||
}
|
||||
if (!record.field_type) {
|
||||
record.field_type = "text";
|
||||
}
|
||||
if (!record.field_id) {
|
||||
record.field_id = connectorId;
|
||||
}
|
||||
|
||||
// Add environment as tenant
|
||||
record.tenant_id = connector.environmentId;
|
||||
|
||||
feedbackRecords.push(record as TCreateFeedbackRecordInput);
|
||||
|
||||
// Send to Hub
|
||||
const { results } = await createFeedbackRecordsBatch(feedbackRecords);
|
||||
|
||||
const successCount = results.filter((r) => r.data && !r.error).length;
|
||||
const errorCount = results.filter((r) => r.error).length;
|
||||
|
||||
if (errorCount > 0) {
|
||||
logger.error(
|
||||
{ connectorId, errors: results.filter((r) => r.error).map((r) => r.error) },
|
||||
"Some feedback records failed to create"
|
||||
);
|
||||
}
|
||||
|
||||
// Update connector last sync time
|
||||
await prisma.connector.update({
|
||||
where: { id: connectorId },
|
||||
data: {
|
||||
lastSyncAt: new Date(),
|
||||
errorMessage: errorCount > 0 ? `${errorCount} records failed` : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ connectorId, successCount, errorCount }, "Webhook processed");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Webhook processed",
|
||||
mode: "production",
|
||||
records_created: successCount,
|
||||
records_failed: errorCount,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error, connectorId }, "Error processing webhook");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/unify/webhook/[connectorId]
|
||||
*
|
||||
* Poll for a received webhook payload during setup phase.
|
||||
* Returns the stored payload if one exists.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
props: { params: Promise<{ connectorId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const { connectorId } = await props.params;
|
||||
|
||||
if (!connectorId) {
|
||||
return NextResponse.json({ error: "Connector ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify connector exists
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: { id: connectorId },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
|
||||
if (!connector) {
|
||||
return NextResponse.json({ error: "Connector not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connector.type !== "webhook") {
|
||||
return NextResponse.json({ error: "This endpoint is only for webhook connectors" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the stored payload (and clear it)
|
||||
const payload = getPayload(connectorId, true);
|
||||
|
||||
if (!payload) {
|
||||
// No payload received yet
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ payload }, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error, connectorId }, "Error retrieving webhook payload");
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTIONS /api/unify/webhook/[connectorId]
|
||||
* Handle CORS preflight requests
|
||||
*/
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a nested value from an object using dot notation and array brackets
|
||||
* Supports paths like: "form_response.answers[0].text" or "data.items[2].name"
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
let current: unknown = obj;
|
||||
|
||||
// Split by dots, but we need to handle array notation within each segment
|
||||
const segments = path.split(".");
|
||||
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if segment contains array notation like "answers[0]" or just "[0]"
|
||||
const arrayMatch = segment.match(/^([^\[]*)\[(\d+)\]$/);
|
||||
|
||||
if (arrayMatch) {
|
||||
const [, propertyName, indexStr] = arrayMatch;
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
// If there's a property name before the bracket, access it first
|
||||
if (propertyName) {
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[propertyName];
|
||||
}
|
||||
|
||||
// Now access the array index
|
||||
if (!Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[index];
|
||||
} else {
|
||||
// Regular property access
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
@@ -242,6 +242,7 @@ checksums:
|
||||
common/logout: 07948fdf20705e04a7bf68ab197512bf
|
||||
common/look_and_feel: 9125503712626d495cedec7a79f1418c
|
||||
common/manage: a3d40c0267b81ae53c9598eaeb05087d
|
||||
common/mappings: 938751312ce179df491c94c1243546e7
|
||||
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
common/member: 1606dc30b369856b9dba1fe9aec425d2
|
||||
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
@@ -1931,6 +1932,112 @@ checksums:
|
||||
environments/surveys/templates/multiple_industries: 7dcb6f6d87feb08f8004dfb5a91e711f
|
||||
environments/surveys/templates/use_this_template: 69020c8b5a521b8f027616bb5c4b64dd
|
||||
environments/surveys/templates/uses_branching_logic: 7ac087d7067d342c17809d4ce497dfe0
|
||||
environments/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
|
||||
environments/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
|
||||
environments/unify/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
|
||||
environments/unify/automated: 040d99fc1e8649d9bfac6e45759ff119
|
||||
environments/unify/aws_region: 6d7132311a69d6288cee9dbfec27227a
|
||||
environments/unify/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
environments/unify/cancel: 2e2a849c2223911717de8caa2c71bade
|
||||
environments/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
environments/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
|
||||
environments/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
|
||||
environments/unify/coming_soon: ee2b0671e00972773210c5be5a9ccb89
|
||||
environments/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
|
||||
environments/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
|
||||
environments/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
|
||||
environments/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
|
||||
environments/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
|
||||
environments/unify/copied: 0d1b21bf6919e363f5c4a4ac75dab210
|
||||
environments/unify/copy: 627c00d2c850b9b45f7341a6ac01b6bb
|
||||
environments/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
|
||||
environments/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
|
||||
environments/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
|
||||
environments/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
environments/unify/delete_source: f1efd5e1c403192a063b761ddfeaf34a
|
||||
environments/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
|
||||
environments/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
environments/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
environments/unify/drop_zone_path: 8e60cc5a0b7b74fe624cfdc0b11a884d
|
||||
environments/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
|
||||
environments/unify/element_selected: d5efff66731075482ad9db319a3b6c70
|
||||
environments/unify/elements_selected: 5de002e01cdddec927585d72715c47b8
|
||||
environments/unify/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
environments/unify/enable_auto_sync: d23ed425a77525a905365631b068ab93
|
||||
environments/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
environments/unify/every_15_minutes: 82b7ca02645256b843b92e3629429f02
|
||||
environments/unify/every_30_minutes: 6bba217e921f55cad68948d6136d23c0
|
||||
environments/unify/every_5_minutes: 4ecba56de234044216c3db522f542109
|
||||
environments/unify/every_hour: 1314cadc59cef3d1f63f59c30f58fba1
|
||||
environments/unify/failed_to_copy: 6233aaa405266fade46ee80a0ab171e8
|
||||
environments/unify/failed_to_create_connector: 9b6c42dc8f7cf10c23c0f480bb76a053
|
||||
environments/unify/failed_to_delete_connector: 3980284a3b92a56a15d73ca5a9fe145b
|
||||
environments/unify/failed_to_load_data: b9e0e9d299cb74625db5bc54765614ab
|
||||
environments/unify/failed_to_update_connector: 3ea1730a7df00707db7538191c37eb3d
|
||||
environments/unify/feedback_date: 4ada116cc8375dd67483108eeb0ddfe8
|
||||
environments/unify/field: 87d7b2d449e2231e5d75ff64015a8cf3
|
||||
environments/unify/fields: 3b02117e12872bf0cd2b6056728216e8
|
||||
environments/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
|
||||
environments/unify/hub_feedback_record_fields: d8e7b6bb8b7c45d8bd69e5f32193dde4
|
||||
environments/unify/iam_configuration_required: 2da3c3c5fd9de01c815204c33e0baf58
|
||||
environments/unify/iam_setup_instructions: f165c08df18347c0692a45b9fc846a6c
|
||||
environments/unify/import_csv_data: e5f873b0e6116c5144677acf38607f2e
|
||||
environments/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
|
||||
environments/unify/n_elements: a3f287f12125f237ba20da58c028a26c
|
||||
environments/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
environments/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
environments/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
|
||||
environments/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
environments/unify/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
environments/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
|
||||
environments/unify/process_new_files_description: a739c0cc86c92940fe1302ba1ad244e5
|
||||
environments/unify/processing_interval: ff66d16920ad6815efeaabf6f61fc260
|
||||
environments/unify/region_ap_southeast_1: 6932d3023a19c776499445f1f415394d
|
||||
environments/unify/region_eu_central_1: 8476430efbe3f4edb9295d5c6e6d05f9
|
||||
environments/unify/region_eu_west_1: f543a1457e57e68c0b19e01bf7351b8f
|
||||
environments/unify/region_us_east_1: 606343effd2647363eda831cb1fcc494
|
||||
environments/unify/region_us_west_2: 45165afea3626c112b9e850fb88c0d5d
|
||||
environments/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
environments/unify/s3_bucket_description: 45ca98a3e925254b831969930ef00953
|
||||
environments/unify/s3_bucket_integration: 9095ce49ee205bb39065928b527c37fa
|
||||
environments/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
environments/unify/select_a_survey_to_see_elements: e549e92e8e2fda4fc6cfc62661a4b328
|
||||
environments/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
|
||||
environments/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
environments/unify/select_elements: c336db5308ff54b1dd8b717fad7dbaff
|
||||
environments/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
|
||||
environments/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
|
||||
environments/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
|
||||
environments/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
environments/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
|
||||
environments/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
|
||||
environments/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
|
||||
environments/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
|
||||
environments/unify/showing_rows: dc47661ac5509aa04ab145798e2e9aa7
|
||||
environments/unify/slack_message: e9f42776940c8a2a265d794a1fa1ed32
|
||||
environments/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
environments/unify/source_connect_email_description: ecf34ce9e57fa2571993fe12f4796e55
|
||||
environments/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
environments/unify/source_connect_slack_description: 43d47f512b4d82ec7ec5f7aaec0c1278
|
||||
environments/unify/source_connect_webhook_description: 17783002c78f996b61c2752d4f31e0a3
|
||||
environments/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
environments/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
environments/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
|
||||
environments/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
|
||||
environments/unify/status_active: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
environments/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
environments/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
environments/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
|
||||
environments/unify/suggest_mapping: 85b08aa84423b425bb7a353f1526d637
|
||||
environments/unify/survey_has_no_elements: 0379106932976c0a61119a20992d4b18
|
||||
environments/unify/test_connection: 6bddfcf3e2a1e806057514093a3fe071
|
||||
environments/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
|
||||
environments/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
|
||||
environments/unify/upload_csv_data_description: 61ff18cadfd21ef9820a203bb035d616
|
||||
environments/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
|
||||
environments/unify/view_setup_guide: 3edf6288a06af663cff24a74cbcba235
|
||||
environments/unify/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
|
||||
environments/unify/yes_delete: 7a260e784409a9112f77d213754cd3e0
|
||||
environments/workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
|
||||
environments/workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
|
||||
environments/workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
|
||||
|
||||
@@ -216,58 +216,6 @@ export function transformResponseToFeedbackRecords(
|
||||
return feedbackRecords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a webhook payload to a FeedbackRecord using field mappings
|
||||
*
|
||||
* @param payload - The incoming webhook payload
|
||||
* @param mappings - The field mappings for this connector
|
||||
* @returns FeedbackRecord payload to send to the Hub
|
||||
*/
|
||||
export function transformWebhookPayloadToFeedbackRecord(
|
||||
payload: Record<string, unknown>,
|
||||
mappings: Array<{
|
||||
sourceFieldId: string;
|
||||
targetFieldId: string;
|
||||
staticValue?: string | null;
|
||||
}>
|
||||
): TCreateFeedbackRecordInput {
|
||||
const feedbackRecord: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
let value: unknown;
|
||||
|
||||
if (mapping.staticValue) {
|
||||
// Use static value
|
||||
value = mapping.staticValue;
|
||||
|
||||
// Handle special static values
|
||||
if (value === "$now") {
|
||||
value = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
// Get value from payload using dot notation path
|
||||
value = getNestedValue(payload, mapping.sourceFieldId);
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
feedbackRecord[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure required fields have defaults
|
||||
if (!feedbackRecord.source_type) {
|
||||
feedbackRecord.source_type = "webhook";
|
||||
}
|
||||
if (!feedbackRecord.collected_at) {
|
||||
feedbackRecord.collected_at = new Date().toISOString();
|
||||
}
|
||||
if (!feedbackRecord.field_type) {
|
||||
feedbackRecord.field_type = "text";
|
||||
}
|
||||
|
||||
return feedbackRecord as TCreateFeedbackRecordInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a CSV row to a FeedbackRecord using field mappings
|
||||
*
|
||||
@@ -333,51 +281,3 @@ export function transformCSVRowToFeedbackRecord(
|
||||
|
||||
return feedbackRecord as TCreateFeedbackRecordInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a nested value from an object using dot notation and array brackets
|
||||
* e.g., getNestedValue({user: {id: "123"}}, "user.id") => "123"
|
||||
* e.g., getNestedValue({items: [{name: "a"}]}, "items[0].name") => "a"
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
let current: unknown = obj;
|
||||
|
||||
// Split by dots, but we need to handle array notation within each segment
|
||||
const segments = path.split(".");
|
||||
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if segment contains array notation like "answers[0]" or just "[0]"
|
||||
const arrayMatch = segment.match(/^([^\[]*)\[(\d+)\]$/);
|
||||
|
||||
if (arrayMatch) {
|
||||
const [, propertyName, indexStr] = arrayMatch;
|
||||
const index = parseInt(indexStr, 10);
|
||||
|
||||
// If there's a property name before the bracket, access it first
|
||||
if (propertyName) {
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[propertyName];
|
||||
}
|
||||
|
||||
// Now access the array index
|
||||
if (!Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[index];
|
||||
} else {
|
||||
// Regular property access
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import "server-only";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
// Session TTL in milliseconds (5 minutes)
|
||||
const SESSION_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Maximum payload size in bytes (100KB)
|
||||
const MAX_PAYLOAD_SIZE = 100 * 1024;
|
||||
|
||||
// Cleanup interval in milliseconds (1 minute)
|
||||
const CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
interface WebhookSession {
|
||||
payload: Record<string, unknown>;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
// In-memory store for webhook payloads
|
||||
const sessionStore = new Map<string, WebhookSession>();
|
||||
|
||||
// Track if cleanup interval is running
|
||||
let cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Generate a unique session ID for webhook listening
|
||||
*/
|
||||
export function generateSessionId(): string {
|
||||
return nanoid(21);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a received webhook payload for a session
|
||||
* @returns true if stored successfully, false if payload is too large or session doesn't exist
|
||||
*/
|
||||
export function storePayload(sessionId: string, payload: Record<string, unknown>): boolean {
|
||||
// Check payload size
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
if (payloadStr.length > MAX_PAYLOAD_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionStore.set(sessionId, {
|
||||
payload,
|
||||
receivedAt: Date.now(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the received payload for a session
|
||||
* @param clear - If true, removes the payload after retrieval (default: true)
|
||||
* @returns The payload if found, null otherwise
|
||||
*/
|
||||
export function getPayload(sessionId: string, clear: boolean = true): Record<string, unknown> | null {
|
||||
const session = sessionStore.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if session has expired
|
||||
if (Date.now() - session.receivedAt > SESSION_TTL_MS) {
|
||||
sessionStore.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { payload } = session;
|
||||
|
||||
if (clear) {
|
||||
sessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists (for validation)
|
||||
*/
|
||||
export function sessionExists(sessionId: string): boolean {
|
||||
return sessionStore.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new listening session
|
||||
* @returns The session ID
|
||||
*/
|
||||
export function createSession(): string {
|
||||
const sessionId = generateSessionId();
|
||||
// Initialize with empty marker to indicate session is active
|
||||
// This doesn't store a payload yet, just marks the session as valid
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
*/
|
||||
export function deleteSession(sessionId: string): void {
|
||||
sessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions
|
||||
*/
|
||||
export function cleanupExpiredSessions(): number {
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [sessionId, session] of sessionStore.entries()) {
|
||||
if (now - session.receivedAt > SESSION_TTL_MS) {
|
||||
sessionStore.delete(sessionId);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the automatic cleanup interval
|
||||
*/
|
||||
export function startCleanupInterval(): void {
|
||||
if (cleanupIntervalId) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
cleanupIntervalId = setInterval(() => {
|
||||
cleanupExpiredSessions();
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Prevent the interval from keeping the process alive
|
||||
if (cleanupIntervalId.unref) {
|
||||
cleanupIntervalId.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the automatic cleanup interval
|
||||
*/
|
||||
export function stopCleanupInterval(): void {
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
cleanupIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current number of active sessions (for debugging/monitoring)
|
||||
*/
|
||||
export function getSessionCount(): number {
|
||||
return sessionStore.size;
|
||||
}
|
||||
|
||||
// Start cleanup on module load
|
||||
startCleanupInterval();
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Abmelden",
|
||||
"look_and_feel": "Darstellung",
|
||||
"manage": "Verwalten",
|
||||
"mappings": "Zuordnungen",
|
||||
"marketing": "Marketing",
|
||||
"member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Diese Umfrage verwendet Logik."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Feedback-Quelle hinzufügen",
|
||||
"add_source": "Quelle hinzufügen",
|
||||
"are_you_sure": "Bist Du sicher?",
|
||||
"automated": "Automatisiert",
|
||||
"aws_region": "AWS-Region",
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen",
|
||||
"change_file": "Datei ändern",
|
||||
"click_load_sample_csv": "Klicke auf 'Beispiel-CSV laden', um Spalten zu sehen",
|
||||
"click_to_upload": "Klicke zum Hochladen",
|
||||
"coming_soon": "Kommt bald",
|
||||
"configure_import": "Import konfigurieren",
|
||||
"configure_mapping": "Zuordnung konfigurieren",
|
||||
"connector_created_successfully": "Connector erfolgreich erstellt",
|
||||
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
|
||||
"connector_updated_successfully": "Connector erfolgreich aktualisiert",
|
||||
"copied": "Kopiert!",
|
||||
"copy": "Kopieren",
|
||||
"create_mapping": "Zuordnung erstellen",
|
||||
"csv_columns": "CSV-Spalten",
|
||||
"csv_files_only": "Nur CSV-Dateien",
|
||||
"csv_import": "CSV-Import",
|
||||
"delete_source": "Quelle löschen",
|
||||
"deselect_all": "Alle abwählen",
|
||||
"drop_a_field_here": "Feld hier ablegen",
|
||||
"drop_field_or": "Feld ablegen oder",
|
||||
"drop_zone_path": "Ablagebereich-Pfad",
|
||||
"edit_source_connection": "Quellverbindung bearbeiten",
|
||||
"element_selected": "<strong>{{count}}</strong> Element ausgewählt. Jede Antwort auf dieses Element erstellt einen FeedbackRecord im Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> Elemente ausgewählt. Jede Antwort auf diese Elemente erstellt einen FeedbackRecord im Hub.",
|
||||
"email": "E-Mail",
|
||||
"enable_auto_sync": "Auto-Sync aktivieren",
|
||||
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
|
||||
"every_15_minutes": "Alle 15 Minuten",
|
||||
"every_30_minutes": "Alle 30 Minuten",
|
||||
"every_5_minutes": "Alle 5 Minuten",
|
||||
"every_hour": "Jede Stunde",
|
||||
"failed_to_copy": "Kopieren fehlgeschlagen",
|
||||
"failed_to_create_connector": "Connector konnte nicht erstellt werden",
|
||||
"failed_to_delete_connector": "Connector konnte nicht gelöscht werden",
|
||||
"failed_to_load_data": "Daten konnten nicht geladen werden",
|
||||
"failed_to_update_connector": "Connector konnte nicht aktualisiert werden",
|
||||
"feedback_date": "Feedback-Datum",
|
||||
"field": "Feld",
|
||||
"fields": "Felder",
|
||||
"formbricks_surveys": "Formbricks Umfragen",
|
||||
"hub_feedback_record_fields": "Hub Feedback-Record-Felder",
|
||||
"iam_configuration_required": "IAM-Konfiguration erforderlich",
|
||||
"iam_setup_instructions": "Füge die Formbricks IAM-Rolle zu deiner S3-Bucket-Policy hinzu, um den Zugriff zu ermöglichen.",
|
||||
"import_csv_data": "CSV-Daten importieren",
|
||||
"load_sample_csv": "Beispiel-CSV laden",
|
||||
"n_elements": "{{count}} Elemente",
|
||||
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
|
||||
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
|
||||
"no_surveys_found": "Keine Umfragen in dieser Umgebung gefunden",
|
||||
"optional": "Optional",
|
||||
"or": "oder",
|
||||
"or_drag_and_drop": "oder per Drag & Drop",
|
||||
"process_new_files_description": "Neue Dateien, die im Bucket abgelegt werden, automatisch verarbeiten",
|
||||
"processing_interval": "Verarbeitungsintervall",
|
||||
"region_ap_southeast_1": "Asien-Pazifik (Singapur)",
|
||||
"region_eu_central_1": "EU (Frankfurt)",
|
||||
"region_eu_west_1": "EU (Irland)",
|
||||
"region_us_east_1": "US Ost (N. Virginia)",
|
||||
"region_us_west_2": "US West (Oregon)",
|
||||
"required": "Erforderlich",
|
||||
"s3_bucket_description": "Lege CSV-Dateien in deinem S3-Bucket ab, um Feedback automatisch zu importieren. Dateien werden alle 15 Minuten verarbeitet.",
|
||||
"s3_bucket_integration": "S3-Bucket-Integration",
|
||||
"save_changes": "Änderungen speichern",
|
||||
"select_a_survey_to_see_elements": "Wähle eine Umfrage aus, um ihre Elemente zu sehen",
|
||||
"select_a_value": "Wähle einen Wert aus...",
|
||||
"select_all": "Alles auswählen",
|
||||
"select_elements": "Elemente auswählen",
|
||||
"select_questions": "Fragen auswählen",
|
||||
"select_source_type_description": "Wähle den Typ der Feedbackquelle aus, die du verbinden möchtest.",
|
||||
"select_source_type_prompt": "Wähle den Typ der Feedbackquelle, die du verbinden möchtest:",
|
||||
"select_survey": "Umfrage auswählen",
|
||||
"select_survey_and_questions": "Umfrage & Fragen auswählen",
|
||||
"select_survey_questions_description": "Wähle aus, welche Umfragefragen FeedbackRecords erstellen sollen.",
|
||||
"set_value": "Wert festlegen",
|
||||
"setup_connection": "Verbindung einrichten",
|
||||
"showing_rows": "3 von {{count}} Zeilen werden angezeigt",
|
||||
"slack_message": "Slack-Nachricht",
|
||||
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
|
||||
"source_connect_email_description": "Feedback aus E-Mails mit benutzerdefiniertem Mapping importieren",
|
||||
"source_connect_formbricks_description": "Feedback aus deinen Formbricks-Umfragen verbinden",
|
||||
"source_connect_slack_description": "Feedback aus Slack-Channels verbinden",
|
||||
"source_connect_webhook_description": "Feedback per Webhook mit benutzerdefiniertem Mapping empfangen",
|
||||
"source_fields": "Quellenfelder",
|
||||
"source_name": "Quellenname",
|
||||
"source_type_cannot_be_changed": "Quellentyp kann nicht geändert werden",
|
||||
"sources": "Quellen",
|
||||
"status_active": "Aktiv",
|
||||
"status_completed": "Abgeschlossen",
|
||||
"status_draft": "Entwurf",
|
||||
"status_paused": "Pausiert",
|
||||
"suggest_mapping": "Mapping vorschlagen",
|
||||
"survey_has_no_elements": "Diese Umfrage hat keine Fragen",
|
||||
"test_connection": "Verbindung testen",
|
||||
"unify_feedback": "Feedback vereinheitlichen",
|
||||
"update_mapping_description": "Aktualisiere die Mapping-Konfiguration für diese Quelle.",
|
||||
"upload_csv_data_description": "Lade eine CSV-Datei hoch oder richte automatisierte S3-Importe ein.",
|
||||
"upload_csv_file": "CSV-Datei hochladen",
|
||||
"view_setup_guide": "Setup-Anleitung ansehen →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Ja, löschen"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "API-Schlüssel hinzufügen",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"invite_not_found": "Invite not found 😥",
|
||||
"invite_not_found_description": "The invitation code cannot be found or has already been used.",
|
||||
"login": "Login",
|
||||
"welcome_to_organization": "You are in \uD83C\uDF89",
|
||||
"welcome_to_organization": "You are in 🎉",
|
||||
"welcome_to_organization_description": "Welcome to the organization."
|
||||
},
|
||||
"last_used": "Last Used",
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Logout",
|
||||
"look_and_feel": "Look & Feel",
|
||||
"manage": "Manage",
|
||||
"mappings": "Mappings",
|
||||
"marketing": "Marketing",
|
||||
"member": "Member",
|
||||
"members": "Members",
|
||||
@@ -520,7 +521,7 @@
|
||||
"select_a_date": "Select a date",
|
||||
"survey_response_finished_email_congrats": "Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}",
|
||||
"survey_response_finished_email_dont_want_notifications": "Do not want to get these notifications?",
|
||||
"survey_response_finished_email_hey": "Hey \uD83D\uDC4B",
|
||||
"survey_response_finished_email_hey": "Hey 👋",
|
||||
"survey_response_finished_email_turn_off_notifications_for_all_new_forms": "Turn off notifications for all newly created forms",
|
||||
"survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form",
|
||||
"survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "This survey uses branching logic."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Add Feedback Source",
|
||||
"add_source": "Add source",
|
||||
"are_you_sure": "Are you sure?",
|
||||
"automated": "Automated",
|
||||
"aws_region": "AWS Region",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"change_file": "Change file",
|
||||
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
|
||||
"click_to_upload": "Click to upload",
|
||||
"coming_soon": "Coming soon",
|
||||
"configure_import": "Configure import",
|
||||
"configure_mapping": "Configure Mapping",
|
||||
"connector_created_successfully": "Connector created successfully",
|
||||
"connector_deleted_successfully": "Connector deleted successfully",
|
||||
"connector_updated_successfully": "Connector updated successfully",
|
||||
"copied": "Copied!",
|
||||
"copy": "Copy",
|
||||
"create_mapping": "Create mapping",
|
||||
"csv_columns": "CSV Columns",
|
||||
"csv_files_only": "CSV files only",
|
||||
"csv_import": "CSV Import",
|
||||
"delete_source": "Delete source",
|
||||
"deselect_all": "Deselect all",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
"drop_field_or": "Drop field or",
|
||||
"drop_zone_path": "Drop zone path",
|
||||
"edit_source_connection": "Edit Source Connection",
|
||||
"element_selected": "<strong>{{count}}</strong> element selected. Each response to these elements will create a FeedbackRecord in the Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> elements selected. Each response to these elements will create a FeedbackRecord in the Hub.",
|
||||
"email": "Email",
|
||||
"enable_auto_sync": "Enable auto-sync",
|
||||
"enter_name_for_source": "Enter a name for this source",
|
||||
"every_15_minutes": "Every 15 minutes",
|
||||
"every_30_minutes": "Every 30 minutes",
|
||||
"every_5_minutes": "Every 5 minutes",
|
||||
"every_hour": "Every hour",
|
||||
"failed_to_copy": "Failed to copy",
|
||||
"failed_to_create_connector": "Failed to create connector",
|
||||
"failed_to_delete_connector": "Failed to delete connector",
|
||||
"failed_to_load_data": "Failed to load data",
|
||||
"failed_to_update_connector": "Failed to update connector",
|
||||
"feedback_date": "Feedback date",
|
||||
"field": "field",
|
||||
"fields": "fields",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"hub_feedback_record_fields": "Hub Feedback Record Fields",
|
||||
"iam_configuration_required": "IAM Configuration Required",
|
||||
"iam_setup_instructions": "Add the Formbricks IAM role to your S3 bucket policy to enable access.",
|
||||
"import_csv_data": "Import CSV Data",
|
||||
"load_sample_csv": "Load sample CSV",
|
||||
"n_elements": "{{count}} elements",
|
||||
"no_source_fields_loaded": "No source fields loaded yet",
|
||||
"no_sources_connected": "No sources connected yet. Add a source to get started.",
|
||||
"no_surveys_found": "No surveys found in this environment",
|
||||
"optional": "Optional",
|
||||
"or": "or",
|
||||
"or_drag_and_drop": "or drag and drop",
|
||||
"process_new_files_description": "Automatically process new files dropped in the bucket",
|
||||
"processing_interval": "Processing interval",
|
||||
"region_ap_southeast_1": "Asia Pacific (Singapore)",
|
||||
"region_eu_central_1": "EU (Frankfurt)",
|
||||
"region_eu_west_1": "EU (Ireland)",
|
||||
"region_us_east_1": "US East (N. Virginia)",
|
||||
"region_us_west_2": "US West (Oregon)",
|
||||
"required": "Required",
|
||||
"s3_bucket_description": "Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15 minutes.",
|
||||
"s3_bucket_integration": "S3 Bucket Integration",
|
||||
"save_changes": "Save changes",
|
||||
"select_a_survey_to_see_elements": "Select a survey to see its elements",
|
||||
"select_a_value": "Select a value...",
|
||||
"select_all": "Select all",
|
||||
"select_elements": "Select Elements",
|
||||
"select_questions": "Select questions",
|
||||
"select_source_type_description": "Select the type of feedback source you want to connect.",
|
||||
"select_source_type_prompt": "Select the type of feedback source you want to connect:",
|
||||
"select_survey": "Select Survey",
|
||||
"select_survey_and_questions": "Select Survey & Questions",
|
||||
"select_survey_questions_description": "Choose which survey questions should create FeedbackRecords.",
|
||||
"set_value": "set value",
|
||||
"setup_connection": "Setup connection",
|
||||
"showing_rows": "Showing 3 of {{count}} rows",
|
||||
"slack_message": "Slack Message",
|
||||
"source_connect_csv_description": "Import feedback from CSV files",
|
||||
"source_connect_email_description": "Import feedback from email with custom mapping",
|
||||
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
|
||||
"source_connect_slack_description": "Connect feedback from Slack channels",
|
||||
"source_connect_webhook_description": "Receive feedback via webhook with custom mapping",
|
||||
"source_fields": "Source Fields",
|
||||
"source_name": "Source Name",
|
||||
"source_type_cannot_be_changed": "Source type cannot be changed",
|
||||
"sources": "Sources",
|
||||
"status_active": "Active",
|
||||
"status_completed": "Completed",
|
||||
"status_draft": "Draft",
|
||||
"status_paused": "Paused",
|
||||
"suggest_mapping": "Suggest mapping",
|
||||
"survey_has_no_elements": "This survey has no question elements",
|
||||
"test_connection": "Test connection",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Update the mapping configuration for this source.",
|
||||
"upload_csv_data_description": "Upload a CSV file or set up automated S3 imports.",
|
||||
"upload_csv_file": "Upload CSV File",
|
||||
"view_setup_guide": "View setup guide →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Yes, delete"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Add API Key",
|
||||
@@ -2674,7 +2783,7 @@
|
||||
"evaluate_a_product_idea_name": "Evaluate a Product Idea",
|
||||
"evaluate_a_product_idea_question_1_button_label": "Let’s do it!",
|
||||
"evaluate_a_product_idea_question_1_headline": "We love how you use $[projectName]! We would love to pick your brain on a feature idea. Got a minute?",
|
||||
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We respect your time and kept it short \uD83E\uDD38</span></p>",
|
||||
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We respect your time and kept it short 🤸</span></p>",
|
||||
"evaluate_a_product_idea_question_2_headline": "Thanks! How difficult or easy is it for you to [PROBLEM AREA] today?",
|
||||
"evaluate_a_product_idea_question_2_lower_label": "Very difficult",
|
||||
"evaluate_a_product_idea_question_2_upper_label": "Very easy",
|
||||
@@ -2723,8 +2832,8 @@
|
||||
"feature_chaser_question_2_headline": "Which aspect is most important?",
|
||||
"feedback_box_description": "Give your users the chance to seamlessly share what is on their minds.",
|
||||
"feedback_box_name": "Feedback Box",
|
||||
"feedback_box_question_1_choice_1": "Bug report \uD83D\uDC1E",
|
||||
"feedback_box_question_1_choice_2": "Feature Request \uD83D\uDCA1",
|
||||
"feedback_box_question_1_choice_1": "Bug report 🐞",
|
||||
"feedback_box_question_1_choice_2": "Feature Request 💡",
|
||||
"feedback_box_question_1_headline": "What is on your mind, boss?",
|
||||
"feedback_box_question_1_subheader": "Thanks for sharing. We will get back to you asap.",
|
||||
"feedback_box_question_2_headline": "What is broken?",
|
||||
@@ -2845,7 +2954,7 @@
|
||||
"interview_prompt_description": "Invite a specific subset of your users to schedule an interview with your product team.",
|
||||
"interview_prompt_name": "Interview Prompt",
|
||||
"interview_prompt_question_1_button_label": "Book slot",
|
||||
"interview_prompt_question_1_headline": "Do you have 15 min to talk to us? \uD83D\uDE4F",
|
||||
"interview_prompt_question_1_headline": "Do you have 15 min to talk to us? 🙏",
|
||||
"interview_prompt_question_1_html": "You are one of our power users. We would love to interview you briefly!",
|
||||
"long_term_retention_check_in_description": "Gauge long-term user satisfaction, loyalty, and areas for improvement to retain loyal users.",
|
||||
"long_term_retention_check_in_name": "Long-Term Retention Check-In",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Cerrar sesión",
|
||||
"look_and_feel": "Apariencia",
|
||||
"manage": "Gestionar",
|
||||
"mappings": "Asignaciones",
|
||||
"marketing": "Marketing",
|
||||
"member": "Miembro",
|
||||
"members": "Miembros",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Esta encuesta utiliza lógica de ramificación."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Añadir fuente de feedback",
|
||||
"add_source": "Añadir fuente",
|
||||
"are_you_sure": "¿Estás seguro?",
|
||||
"automated": "Automatizado",
|
||||
"aws_region": "Región de AWS",
|
||||
"back": "Atrás",
|
||||
"cancel": "Cancelar",
|
||||
"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",
|
||||
"coming_soon": "Próximamente",
|
||||
"configure_import": "Configurar importación",
|
||||
"configure_mapping": "Configurar asignación",
|
||||
"connector_created_successfully": "Conector creado correctamente",
|
||||
"connector_deleted_successfully": "Conector eliminado correctamente",
|
||||
"connector_updated_successfully": "Conector actualizado correctamente",
|
||||
"copied": "¡Copiado!",
|
||||
"copy": "Copiar",
|
||||
"create_mapping": "Crear asignación",
|
||||
"csv_columns": "Columnas CSV",
|
||||
"csv_files_only": "Solo archivos CSV",
|
||||
"csv_import": "Importación CSV",
|
||||
"delete_source": "Eliminar fuente",
|
||||
"deselect_all": "Deseleccionar todo",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
"drop_field_or": "Suelta el campo o",
|
||||
"drop_zone_path": "Ruta de la zona de destino",
|
||||
"edit_source_connection": "Editar conexión de origen",
|
||||
"element_selected": "<strong>{{count}}</strong> elemento seleccionado. Cada respuesta a este elemento creará un FeedbackRecord en el Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> elementos seleccionados. Cada respuesta a estos elementos creará un FeedbackRecord en el Hub.",
|
||||
"email": "Email",
|
||||
"enable_auto_sync": "Activar sincronización automática",
|
||||
"enter_name_for_source": "Introduce un nombre para este origen",
|
||||
"every_15_minutes": "Cada 15 minutos",
|
||||
"every_30_minutes": "Cada 30 minutos",
|
||||
"every_5_minutes": "Cada 5 minutos",
|
||||
"every_hour": "Cada hora",
|
||||
"failed_to_copy": "Error al copiar",
|
||||
"failed_to_create_connector": "Error al crear el conector",
|
||||
"failed_to_delete_connector": "Error al eliminar el conector",
|
||||
"failed_to_load_data": "Error al cargar los datos",
|
||||
"failed_to_update_connector": "Error al actualizar el conector",
|
||||
"feedback_date": "Fecha del feedback",
|
||||
"field": "campo",
|
||||
"fields": "campos",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"hub_feedback_record_fields": "Campos de FeedbackRecord del Hub",
|
||||
"iam_configuration_required": "Se requiere configuración de IAM",
|
||||
"iam_setup_instructions": "Añade el rol de IAM de Formbricks a la política de tu bucket de S3 para habilitar el acceso.",
|
||||
"import_csv_data": "Importar datos CSV",
|
||||
"load_sample_csv": "Cargar CSV de muestra",
|
||||
"n_elements": "{{count}} elementos",
|
||||
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
|
||||
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
|
||||
"no_surveys_found": "No se encontraron encuestas en este entorno",
|
||||
"optional": "Opcional",
|
||||
"or": "o",
|
||||
"or_drag_and_drop": "o arrastra y suelta",
|
||||
"process_new_files_description": "Procesar automáticamente los archivos nuevos depositados en el bucket",
|
||||
"processing_interval": "Intervalo de procesamiento",
|
||||
"region_ap_southeast_1": "Asia Pacífico (Singapur)",
|
||||
"region_eu_central_1": "UE (Fráncfort)",
|
||||
"region_eu_west_1": "UE (Irlanda)",
|
||||
"region_us_east_1": "EE. UU. Este (N. Virginia)",
|
||||
"region_us_west_2": "EE. UU. Oeste (Oregón)",
|
||||
"required": "Obligatorio",
|
||||
"s3_bucket_description": "Deposita archivos CSV en tu bucket de S3 para importar comentarios automáticamente. Los archivos se procesan cada 15 minutos.",
|
||||
"s3_bucket_integration": "Integración con bucket de S3",
|
||||
"save_changes": "Guardar cambios",
|
||||
"select_a_survey_to_see_elements": "Selecciona una encuesta para ver sus elementos",
|
||||
"select_a_value": "Selecciona un valor...",
|
||||
"select_all": "Seleccionar todo",
|
||||
"select_elements": "Seleccionar elementos",
|
||||
"select_questions": "Seleccionar preguntas",
|
||||
"select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.",
|
||||
"select_source_type_prompt": "Selecciona el tipo de fuente de feedback que quieres conectar:",
|
||||
"select_survey": "Seleccionar encuesta",
|
||||
"select_survey_and_questions": "Seleccionar encuesta y preguntas",
|
||||
"select_survey_questions_description": "Elige qué preguntas de la encuesta deben crear FeedbackRecords.",
|
||||
"set_value": "establecer valor",
|
||||
"setup_connection": "Configurar conexión",
|
||||
"showing_rows": "Mostrando 3 de {{count}} filas",
|
||||
"slack_message": "Mensaje de Slack",
|
||||
"source_connect_csv_description": "Importar feedback desde archivos CSV",
|
||||
"source_connect_email_description": "Importar feedback desde email con mapeo personalizado",
|
||||
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
|
||||
"source_connect_slack_description": "Conectar feedback desde canales de Slack",
|
||||
"source_connect_webhook_description": "Recibir feedback vía webhook con mapeo personalizado",
|
||||
"source_fields": "Campos de origen",
|
||||
"source_name": "Nombre de origen",
|
||||
"source_type_cannot_be_changed": "El tipo de origen no se puede cambiar",
|
||||
"sources": "Orígenes",
|
||||
"status_active": "Activo",
|
||||
"status_completed": "Completado",
|
||||
"status_draft": "Borrador",
|
||||
"status_paused": "Pausado",
|
||||
"suggest_mapping": "Sugerir mapeo",
|
||||
"survey_has_no_elements": "Esta encuesta no tiene elementos de pregunta",
|
||||
"test_connection": "Probar conexión",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Actualiza la configuración de mapeo para esta fuente.",
|
||||
"upload_csv_data_description": "Sube un archivo CSV o configura importaciones automatizadas desde S3.",
|
||||
"upload_csv_file": "Subir archivo CSV",
|
||||
"view_setup_guide": "Ver guía de configuración →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Sí, eliminar"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Añadir clave API",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Déconnexion",
|
||||
"look_and_feel": "Apparence",
|
||||
"manage": "Gérer",
|
||||
"mappings": "Mappages",
|
||||
"marketing": "Marketing",
|
||||
"member": "Membre",
|
||||
"members": "Membres",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Cette enquête utilise une logique de branchement."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Ajouter une source de feedback",
|
||||
"add_source": "Ajouter une source",
|
||||
"are_you_sure": "Es-tu sûr ?",
|
||||
"automated": "Automatisé",
|
||||
"aws_region": "Région AWS",
|
||||
"back": "Retour",
|
||||
"cancel": "Annuler",
|
||||
"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",
|
||||
"coming_soon": "À venir bientôt",
|
||||
"configure_import": "Configurer l'importation",
|
||||
"configure_mapping": "Configurer le mappage",
|
||||
"connector_created_successfully": "Connecteur créé avec succès",
|
||||
"connector_deleted_successfully": "Connecteur supprimé avec succès",
|
||||
"connector_updated_successfully": "Connecteur mis à jour avec succès",
|
||||
"copied": "Copié !",
|
||||
"copy": "Copier",
|
||||
"create_mapping": "Créer un mappage",
|
||||
"csv_columns": "Colonnes CSV",
|
||||
"csv_files_only": "Fichiers CSV uniquement",
|
||||
"csv_import": "Importation CSV",
|
||||
"delete_source": "Supprimer la source",
|
||||
"deselect_all": "Tout désélectionner",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
"drop_field_or": "Déposez un champ ou",
|
||||
"drop_zone_path": "Chemin de la zone de dépôt",
|
||||
"edit_source_connection": "Modifier la connexion source",
|
||||
"element_selected": "<strong>{{count}}</strong> élément sélectionné. Chaque réponse à cet élément créera un enregistrement de feedback dans le Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> éléments sélectionnés. Chaque réponse à ces éléments créera un enregistrement de feedback dans le Hub.",
|
||||
"email": "Email",
|
||||
"enable_auto_sync": "Activer la synchronisation automatique",
|
||||
"enter_name_for_source": "Entrez un nom pour cette source",
|
||||
"every_15_minutes": "Toutes les 15 minutes",
|
||||
"every_30_minutes": "Toutes les 30 minutes",
|
||||
"every_5_minutes": "Toutes les 5 minutes",
|
||||
"every_hour": "Toutes les heures",
|
||||
"failed_to_copy": "Échec de la copie",
|
||||
"failed_to_create_connector": "Échec de la création du connecteur",
|
||||
"failed_to_delete_connector": "Échec de la suppression du connecteur",
|
||||
"failed_to_load_data": "Échec du chargement des données",
|
||||
"failed_to_update_connector": "Échec de la mise à jour du connecteur",
|
||||
"feedback_date": "Date du feedback",
|
||||
"field": "champ",
|
||||
"fields": "champs",
|
||||
"formbricks_surveys": "Sondages Formbricks",
|
||||
"hub_feedback_record_fields": "Champs d'enregistrement de feedback du Hub",
|
||||
"iam_configuration_required": "Configuration IAM requise",
|
||||
"iam_setup_instructions": "Ajoutez le rôle IAM Formbricks à la politique de votre bucket S3 pour activer l'accès.",
|
||||
"import_csv_data": "Importer des données CSV",
|
||||
"load_sample_csv": "Charger un exemple de CSV",
|
||||
"n_elements": "{{count}} éléments",
|
||||
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
|
||||
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
|
||||
"no_surveys_found": "Aucune enquête trouvée dans cet environnement",
|
||||
"optional": "Facultatif",
|
||||
"or": "ou",
|
||||
"or_drag_and_drop": "ou glisser-déposer",
|
||||
"process_new_files_description": "Traiter automatiquement les nouveaux fichiers déposés dans le bucket",
|
||||
"processing_interval": "Intervalle de traitement",
|
||||
"region_ap_southeast_1": "Asie-Pacifique (Singapour)",
|
||||
"region_eu_central_1": "UE (Francfort)",
|
||||
"region_eu_west_1": "UE (Irlande)",
|
||||
"region_us_east_1": "Est des États-Unis (Virginie du Nord)",
|
||||
"region_us_west_2": "Ouest des États-Unis (Oregon)",
|
||||
"required": "Requis",
|
||||
"s3_bucket_description": "Déposez des fichiers CSV dans votre bucket S3 pour importer automatiquement les retours. Les fichiers sont traités toutes les 15 minutes.",
|
||||
"s3_bucket_integration": "Intégration de bucket S3",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"select_a_survey_to_see_elements": "Sélectionnez une enquête pour voir ses éléments",
|
||||
"select_a_value": "Sélectionnez une valeur...",
|
||||
"select_all": "Sélectionner tout",
|
||||
"select_elements": "Sélectionner les éléments",
|
||||
"select_questions": "Sélectionner les questions",
|
||||
"select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.",
|
||||
"select_source_type_prompt": "Sélectionnez le type de source de feedback que vous souhaitez connecter :",
|
||||
"select_survey": "Sélectionner l'enquête",
|
||||
"select_survey_and_questions": "Sélectionner l'enquête et les questions",
|
||||
"select_survey_questions_description": "Choisissez quelles questions d'enquête doivent créer des FeedbackRecords.",
|
||||
"set_value": "définir la valeur",
|
||||
"setup_connection": "Configurer la connexion",
|
||||
"showing_rows": "Affichage de 3 sur {{count}} lignes",
|
||||
"slack_message": "Message Slack",
|
||||
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
|
||||
"source_connect_email_description": "Importer des feedbacks depuis des emails avec mappage personnalisé",
|
||||
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
|
||||
"source_connect_slack_description": "Connecter les feedbacks depuis les canaux Slack",
|
||||
"source_connect_webhook_description": "Recevoir des feedbacks via webhook avec mappage personnalisé",
|
||||
"source_fields": "Champs source",
|
||||
"source_name": "Nom de la source",
|
||||
"source_type_cannot_be_changed": "Le type de source ne peut pas être modifié",
|
||||
"sources": "Sources",
|
||||
"status_active": "Active",
|
||||
"status_completed": "Terminé",
|
||||
"status_draft": "Brouillon",
|
||||
"status_paused": "En pause",
|
||||
"suggest_mapping": "Suggérer un mappage",
|
||||
"survey_has_no_elements": "Cette enquête n'a aucun élément de question",
|
||||
"test_connection": "Tester la connexion",
|
||||
"unify_feedback": "Unifier les retours",
|
||||
"update_mapping_description": "Mettre à jour la configuration de mappage pour cette source.",
|
||||
"upload_csv_data_description": "Télécharger un fichier CSV ou configurer des imports S3 automatisés.",
|
||||
"upload_csv_file": "Télécharger un fichier CSV",
|
||||
"view_setup_guide": "Voir le guide de configuration →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Oui, supprimer"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Ajouter une clé API",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Kijelentkezés",
|
||||
"look_and_feel": "Megjelenés",
|
||||
"manage": "Kezelés",
|
||||
"mappings": "Leképezések",
|
||||
"marketing": "Marketing",
|
||||
"member": "Tag",
|
||||
"members": "Tagok",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Ez a kérdőív elágazási logikát használ."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Visszajelzési forrás hozzáadása",
|
||||
"add_source": "Forrás hozzáadása",
|
||||
"are_you_sure": "Biztos benne?",
|
||||
"automated": "Automatizált",
|
||||
"aws_region": "AWS régió",
|
||||
"back": "Vissza",
|
||||
"cancel": "Mégse",
|
||||
"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",
|
||||
"coming_soon": "Hamarosan",
|
||||
"configure_import": "Importálás konfigurálása",
|
||||
"configure_mapping": "Leképezés konfigurálása",
|
||||
"connector_created_successfully": "Csatlakozó sikeresen létrehozva",
|
||||
"connector_deleted_successfully": "Csatlakozó sikeresen törölve",
|
||||
"connector_updated_successfully": "Csatlakozó sikeresen frissítve",
|
||||
"copied": "Másolva!",
|
||||
"copy": "Másolás",
|
||||
"create_mapping": "Leképezés létrehozása",
|
||||
"csv_columns": "CSV oszlopok",
|
||||
"csv_files_only": "Csak CSV fájlok",
|
||||
"csv_import": "CSV importálás",
|
||||
"delete_source": "Forrás törlése",
|
||||
"deselect_all": "Összes kijelölés törlése",
|
||||
"drop_a_field_here": "Húzz ide egy mezőt",
|
||||
"drop_field_or": "Húzz ide egy mezőt vagy",
|
||||
"drop_zone_path": "Célterület elérési útja",
|
||||
"edit_source_connection": "Forráskapcsolat szerkesztése",
|
||||
"element_selected": "<strong>{{count}}</strong> elem kiválasztva. Minden válasz ezekre az elemekre létrehoz egy visszajelzési rekordot a központban.",
|
||||
"elements_selected": "<strong>{{count}}</strong> elem kiválasztva. Minden válasz ezekre az elemekre létrehoz egy visszajelzési rekordot a központban.",
|
||||
"email": "E-mail",
|
||||
"enable_auto_sync": "Automatikus szinkronizálás engedélyezése",
|
||||
"enter_name_for_source": "Adj nevet ennek a forrásnak",
|
||||
"every_15_minutes": "15 percenként",
|
||||
"every_30_minutes": "30 percenként",
|
||||
"every_5_minutes": "5 percenként",
|
||||
"every_hour": "Óránként",
|
||||
"failed_to_copy": "Másolás sikertelen",
|
||||
"failed_to_create_connector": "Csatlakozó létrehozása sikertelen",
|
||||
"failed_to_delete_connector": "Csatlakozó törlése sikertelen",
|
||||
"failed_to_load_data": "Adatok betöltése sikertelen",
|
||||
"failed_to_update_connector": "Csatlakozó frissítése sikertelen",
|
||||
"feedback_date": "Visszajelzés dátuma",
|
||||
"field": "mező",
|
||||
"fields": "mezők",
|
||||
"formbricks_surveys": "Formbricks kérdőívek",
|
||||
"hub_feedback_record_fields": "Központi visszajelzési rekord mezők",
|
||||
"iam_configuration_required": "IAM konfiguráció szükséges",
|
||||
"iam_setup_instructions": "Add hozzá a Formbricks IAM szerepkört az S3 bucket szabályzatodhoz a hozzáférés engedélyezéséhez.",
|
||||
"import_csv_data": "CSV adatok importálása",
|
||||
"load_sample_csv": "Minta CSV betöltése",
|
||||
"n_elements": "{{count}} elem",
|
||||
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
|
||||
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
|
||||
"no_surveys_found": "Nem találhatók kérdőívek ebben a környezetben",
|
||||
"optional": "Elhagyható",
|
||||
"or": "vagy",
|
||||
"or_drag_and_drop": "vagy húzd ide",
|
||||
"process_new_files_description": "Új fájlok automatikus feldolgozása a tárolóba helyezéskor",
|
||||
"processing_interval": "Feldolgozási időköz",
|
||||
"region_ap_southeast_1": "Ázsia-Csendes-óceáni térség (Szingapúr)",
|
||||
"region_eu_central_1": "EU (Frankfurt)",
|
||||
"region_eu_west_1": "EU (Írország)",
|
||||
"region_us_east_1": "USA keleti régió (Észak-Virginia)",
|
||||
"region_us_west_2": "USA nyugati régió (Oregon)",
|
||||
"required": "Kötelező",
|
||||
"s3_bucket_description": "Helyezz CSV fájlokat az S3 tárolódba a visszajelzések automatikus importálásához. A fájlok 15 percenként kerülnek feldolgozásra.",
|
||||
"s3_bucket_integration": "S3 tároló integráció",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"select_a_survey_to_see_elements": "Válassz egy kérdőívet az elemek megtekintéséhez",
|
||||
"select_a_value": "Válassz egy értéket...",
|
||||
"select_all": "Összes kiválasztása",
|
||||
"select_elements": "Elemek kiválasztása",
|
||||
"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_source_type_prompt": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát:",
|
||||
"select_survey": "Kérdőív kiválasztása",
|
||||
"select_survey_and_questions": "Kérdőív és kérdések kiválasztása",
|
||||
"select_survey_questions_description": "Válassza ki, mely kérdőívkérdések hozzanak létre visszajelzési rekordokat.",
|
||||
"set_value": "érték beállítása",
|
||||
"setup_connection": "Kapcsolat beállítása",
|
||||
"showing_rows": "{{count}} sorból 3 megjelenítve",
|
||||
"slack_message": "Slack üzenet",
|
||||
"source_connect_csv_description": "Visszajelzések importálása CSV fájlokból",
|
||||
"source_connect_email_description": "Visszajelzések importálása e-mailből egyéni leképezéssel",
|
||||
"source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
|
||||
"source_connect_slack_description": "Visszajelzések csatlakoztatása Slack csatornákból",
|
||||
"source_connect_webhook_description": "Visszajelzések fogadása webhookokon keresztül egyéni leképezéssel",
|
||||
"source_fields": "Forrásmezők",
|
||||
"source_name": "Forrásnév",
|
||||
"source_type_cannot_be_changed": "A forrástípus nem módosítható",
|
||||
"sources": "Források",
|
||||
"status_active": "Aktív",
|
||||
"status_completed": "Befejezve",
|
||||
"status_draft": "Piszkozat",
|
||||
"status_paused": "Szüneteltetve",
|
||||
"suggest_mapping": "Leképezés javaslása",
|
||||
"survey_has_no_elements": "Ez a kérdőív nem tartalmaz kérdéselemeket",
|
||||
"test_connection": "Kapcsolat tesztelése",
|
||||
"unify_feedback": "Visszajelzések egyesítése",
|
||||
"update_mapping_description": "Frissítse a leképezési konfigurációt ehhez a forráshoz.",
|
||||
"upload_csv_data_description": "Töltsön fel egy CSV fájlt, vagy állítson be automatizált S3 importálást.",
|
||||
"upload_csv_file": "CSV fájl feltöltése",
|
||||
"view_setup_guide": "Telepítési útmutató megtekintése →",
|
||||
"webhook": "Webhorog",
|
||||
"yes_delete": "Igen, törlés"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "API-kulcs hozzáadása",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "ログアウト",
|
||||
"look_and_feel": "デザイン",
|
||||
"manage": "管理",
|
||||
"mappings": "マッピング",
|
||||
"marketing": "マーケティング",
|
||||
"member": "メンバー",
|
||||
"members": "メンバー",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "このフォームは分岐ロジックを使用しています。"
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "フィードバックソースを追加",
|
||||
"add_source": "ソースを追加",
|
||||
"are_you_sure": "よろしいですか?",
|
||||
"automated": "自動化",
|
||||
"aws_region": "AWSリージョン",
|
||||
"back": "戻る",
|
||||
"cancel": "キャンセル",
|
||||
"change_file": "ファイルを変更",
|
||||
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"coming_soon": "近日公開",
|
||||
"configure_import": "インポートを設定",
|
||||
"configure_mapping": "マッピングを設定",
|
||||
"connector_created_successfully": "コネクタが正常に作成されました",
|
||||
"connector_deleted_successfully": "コネクタが正常に削除されました",
|
||||
"connector_updated_successfully": "コネクタが正常に更新されました",
|
||||
"copied": "コピーしました!",
|
||||
"copy": "コピー",
|
||||
"create_mapping": "マッピングを作成",
|
||||
"csv_columns": "CSV列",
|
||||
"csv_files_only": "CSVファイルのみ",
|
||||
"csv_import": "CSVインポート",
|
||||
"delete_source": "ソースを削除",
|
||||
"deselect_all": "すべて選択解除",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
"drop_field_or": "フィールドをドロップまたは",
|
||||
"drop_zone_path": "ドロップゾーンのパス",
|
||||
"edit_source_connection": "ソース接続を編集",
|
||||
"element_selected": "<strong>{{count}}</strong> 個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。",
|
||||
"elements_selected": "<strong>{{count}}</strong> 個の要素が選択されています。これらの要素への各回答は、ハブにフィードバックレコードを作成します。",
|
||||
"email": "メールアドレス",
|
||||
"enable_auto_sync": "自動同期を有効にする",
|
||||
"enter_name_for_source": "このソースの名前を入力",
|
||||
"every_15_minutes": "15分ごと",
|
||||
"every_30_minutes": "30分ごと",
|
||||
"every_5_minutes": "5分ごと",
|
||||
"every_hour": "1時間ごと",
|
||||
"failed_to_copy": "コピーに失敗しました",
|
||||
"failed_to_create_connector": "コネクタの作成に失敗しました",
|
||||
"failed_to_delete_connector": "コネクタの削除に失敗しました",
|
||||
"failed_to_load_data": "データの読み込みに失敗しました",
|
||||
"failed_to_update_connector": "コネクタの更新に失敗しました",
|
||||
"feedback_date": "フィードバック日時",
|
||||
"field": "フィールド",
|
||||
"fields": "フィールド",
|
||||
"formbricks_surveys": "Formbricks フォーム",
|
||||
"hub_feedback_record_fields": "ハブフィードバックレコードフィールド",
|
||||
"iam_configuration_required": "IAM設定が必要です",
|
||||
"iam_setup_instructions": "S3バケットポリシーにFormbricks IAMロールを追加して、アクセスを有効にしてください。",
|
||||
"import_csv_data": "CSVデータをインポート",
|
||||
"load_sample_csv": "サンプルCSVを読み込む",
|
||||
"n_elements": "{{count}}個の要素",
|
||||
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
|
||||
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
|
||||
"no_surveys_found": "この環境にフォームが見つかりません",
|
||||
"optional": "任意",
|
||||
"or": "または",
|
||||
"or_drag_and_drop": "またはドラッグ&ドロップ",
|
||||
"process_new_files_description": "バケットにドロップされた新しいファイルを自動的に処理します",
|
||||
"processing_interval": "処理間隔",
|
||||
"region_ap_southeast_1": "アジアパシフィック(シンガポール)",
|
||||
"region_eu_central_1": "EU(フランクフルト)",
|
||||
"region_eu_west_1": "EU(アイルランド)",
|
||||
"region_us_east_1": "米国東部(バージニア北部)",
|
||||
"region_us_west_2": "米国西部(オレゴン)",
|
||||
"required": "必須",
|
||||
"s3_bucket_description": "S3バケットにCSVファイルをドロップすると、フィードバックが自動的にインポートされます。ファイルは15分ごとに処理されます。",
|
||||
"s3_bucket_integration": "S3バケット連携",
|
||||
"save_changes": "変更を保存",
|
||||
"select_a_survey_to_see_elements": "フォームを選択して要素を表示",
|
||||
"select_a_value": "値を選択...",
|
||||
"select_all": "すべて選択",
|
||||
"select_elements": "要素を選択",
|
||||
"select_questions": "質問を選択",
|
||||
"select_source_type_description": "接続するフィードバックソースの種類を選択してください。",
|
||||
"select_source_type_prompt": "接続するフィードバックソースの種類を選択してください:",
|
||||
"select_survey": "フォームを選択",
|
||||
"select_survey_and_questions": "フォームと質問を選択",
|
||||
"select_survey_questions_description": "フィードバックレコードを作成するフォームの質問を選択してください。",
|
||||
"set_value": "値を設定",
|
||||
"setup_connection": "接続を設定",
|
||||
"showing_rows": "{{count}}行中3行を表示",
|
||||
"slack_message": "Slackメッセージ",
|
||||
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
|
||||
"source_connect_email_description": "カスタムマッピングでメールからフィードバックをインポート",
|
||||
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
|
||||
"source_connect_slack_description": "Slackチャンネルからフィードバックを接続",
|
||||
"source_connect_webhook_description": "カスタムマッピングでWebhook経由でフィードバックを受信",
|
||||
"source_fields": "ソースフィールド",
|
||||
"source_name": "ソース名",
|
||||
"source_type_cannot_be_changed": "ソースタイプは変更できません",
|
||||
"sources": "ソース",
|
||||
"status_active": "有効",
|
||||
"status_completed": "完了",
|
||||
"status_draft": "下書き",
|
||||
"status_paused": "一時停止",
|
||||
"suggest_mapping": "マッピングを提案",
|
||||
"survey_has_no_elements": "このフォームには質問要素がありません",
|
||||
"test_connection": "接続をテスト",
|
||||
"unify_feedback": "フィードバックを統合",
|
||||
"update_mapping_description": "このソースのマッピング設定を更新します。",
|
||||
"upload_csv_data_description": "CSVファイルをアップロードするか、S3の自動インポートを設定します。",
|
||||
"upload_csv_file": "CSVファイルをアップロード",
|
||||
"view_setup_guide": "セットアップガイドを見る →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "はい、削除します"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "APIキーを追加",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Uitloggen",
|
||||
"look_and_feel": "Kijk & voel",
|
||||
"manage": "Beheren",
|
||||
"mappings": "Koppelingen",
|
||||
"marketing": "Marketing",
|
||||
"member": "Lid",
|
||||
"members": "Leden",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Dit onderzoek maakt gebruik van vertakkingslogica."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Feedbackbron toevoegen",
|
||||
"add_source": "Bron toevoegen",
|
||||
"are_you_sure": "Weet je het zeker?",
|
||||
"automated": "Geautomatiseerd",
|
||||
"aws_region": "AWS regio",
|
||||
"back": "Terug",
|
||||
"cancel": "Annuleren",
|
||||
"change_file": "Bestand wijzigen",
|
||||
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
|
||||
"click_to_upload": "Klik om te uploaden",
|
||||
"coming_soon": "Binnenkort beschikbaar",
|
||||
"configure_import": "Import configureren",
|
||||
"configure_mapping": "Koppeling configureren",
|
||||
"connector_created_successfully": "Connector succesvol aangemaakt",
|
||||
"connector_deleted_successfully": "Connector succesvol verwijderd",
|
||||
"connector_updated_successfully": "Connector succesvol bijgewerkt",
|
||||
"copied": "Gekopieerd!",
|
||||
"copy": "Kopiëren",
|
||||
"create_mapping": "Koppeling aanmaken",
|
||||
"csv_columns": "CSV kolommen",
|
||||
"csv_files_only": "Alleen CSV bestanden",
|
||||
"csv_import": "CSV import",
|
||||
"delete_source": "Bron verwijderen",
|
||||
"deselect_all": "Alles deselecteren",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
"drop_field_or": "Zet veld neer of",
|
||||
"drop_zone_path": "Drop zone pad",
|
||||
"edit_source_connection": "Bronverbinding bewerken",
|
||||
"element_selected": "<strong>{{count}}</strong> element geselecteerd. Elke reactie op dit element zal een FeedbackRecord aanmaken in de Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> elementen geselecteerd. Elke reactie op deze elementen zal een FeedbackRecord aanmaken in de Hub.",
|
||||
"email": "E-mail",
|
||||
"enable_auto_sync": "Automatische synchronisatie inschakelen",
|
||||
"enter_name_for_source": "Voer een naam in voor deze bron",
|
||||
"every_15_minutes": "Elke 15 minuten",
|
||||
"every_30_minutes": "Elke 30 minuten",
|
||||
"every_5_minutes": "Elke 5 minuten",
|
||||
"every_hour": "Elk uur",
|
||||
"failed_to_copy": "Kopiëren mislukt",
|
||||
"failed_to_create_connector": "Connector aanmaken mislukt",
|
||||
"failed_to_delete_connector": "Connector verwijderen mislukt",
|
||||
"failed_to_load_data": "Gegevens laden mislukt",
|
||||
"failed_to_update_connector": "Connector bijwerken mislukt",
|
||||
"feedback_date": "Feedbackdatum",
|
||||
"field": "veld",
|
||||
"fields": "velden",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"hub_feedback_record_fields": "Hub feedbackrecordvelden",
|
||||
"iam_configuration_required": "IAM-configuratie vereist",
|
||||
"iam_setup_instructions": "Voeg de Formbricks IAM-rol toe aan je S3 bucket policy om toegang mogelijk te maken.",
|
||||
"import_csv_data": "CSV-gegevens importeren",
|
||||
"load_sample_csv": "Voorbeeld-CSV laden",
|
||||
"n_elements": "{{count}} elementen",
|
||||
"no_source_fields_loaded": "Nog geen bronvelden geladen",
|
||||
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
|
||||
"no_surveys_found": "Geen enquêtes gevonden in deze omgeving",
|
||||
"optional": "Optioneel",
|
||||
"or": "of",
|
||||
"or_drag_and_drop": "of sleep en zet neer",
|
||||
"process_new_files_description": "Verwerk automatisch nieuwe bestanden die in de bucket worden geplaatst",
|
||||
"processing_interval": "Verwerkingsinterval",
|
||||
"region_ap_southeast_1": "Azië-Pacific (Singapore)",
|
||||
"region_eu_central_1": "EU (Frankfurt)",
|
||||
"region_eu_west_1": "EU (Ierland)",
|
||||
"region_us_east_1": "VS Oost (N. Virginia)",
|
||||
"region_us_west_2": "VS West (Oregon)",
|
||||
"required": "Vereist",
|
||||
"s3_bucket_description": "Plaats CSV-bestanden in je S3-bucket om automatisch feedback te importeren. Bestanden worden elke 15 minuten verwerkt.",
|
||||
"s3_bucket_integration": "S3-bucket integratie",
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"select_a_survey_to_see_elements": "Selecteer een enquête om de elementen te zien",
|
||||
"select_a_value": "Selecteer een waarde...",
|
||||
"select_all": "Selecteer alles",
|
||||
"select_elements": "Selecteer elementen",
|
||||
"select_questions": "Selecteer vragen",
|
||||
"select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.",
|
||||
"select_source_type_prompt": "Selecteer het type feedbackbron dat je wilt verbinden:",
|
||||
"select_survey": "Selecteer enquête",
|
||||
"select_survey_and_questions": "Selecteer enquête & vragen",
|
||||
"select_survey_questions_description": "Kies welke enquêtevragen FeedbackRecords moeten aanmaken.",
|
||||
"set_value": "waarde instellen",
|
||||
"setup_connection": "Verbinding instellen",
|
||||
"showing_rows": "{{count}} van de {{count}} rijen worden weergegeven",
|
||||
"slack_message": "Slack-bericht",
|
||||
"source_connect_csv_description": "Importeer feedback uit CSV-bestanden",
|
||||
"source_connect_email_description": "Importeer feedback uit e-mail met aangepaste mapping",
|
||||
"source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes",
|
||||
"source_connect_slack_description": "Verbind feedback van Slack-kanalen",
|
||||
"source_connect_webhook_description": "Ontvang feedback via webhook met aangepaste mapping",
|
||||
"source_fields": "Bronvelden",
|
||||
"source_name": "Bronnaam",
|
||||
"source_type_cannot_be_changed": "Brontype kan niet worden gewijzigd",
|
||||
"sources": "Bronnen",
|
||||
"status_active": "Actief",
|
||||
"status_completed": "Voltooid",
|
||||
"status_draft": "Voorlopige versie",
|
||||
"status_paused": "Gepauzeerd",
|
||||
"suggest_mapping": "Mapping voorstellen",
|
||||
"survey_has_no_elements": "Deze enquête heeft geen vraagelementen",
|
||||
"test_connection": "Verbinding testen",
|
||||
"unify_feedback": "Feedback verenigen",
|
||||
"update_mapping_description": "Werk de mappingconfiguratie voor deze bron bij.",
|
||||
"upload_csv_data_description": "Upload een CSV-bestand of stel geautomatiseerde S3-imports in.",
|
||||
"upload_csv_file": "CSV-bestand uploaden",
|
||||
"view_setup_guide": "Bekijk installatiegids →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Ja, verwijderen"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "API-sleutel toevoegen",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Sair",
|
||||
"look_and_feel": "Aparência e Experiência",
|
||||
"manage": "gerenciar",
|
||||
"mappings": "Mapeamentos",
|
||||
"marketing": "marketing",
|
||||
"member": "Membros",
|
||||
"members": "Membros",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Essa pesquisa usa lógica de ramificação."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"are_you_sure": "Certeza?",
|
||||
"automated": "Automatizado",
|
||||
"aws_region": "Região AWS",
|
||||
"back": "Voltar",
|
||||
"cancel": "Cancelar",
|
||||
"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",
|
||||
"coming_soon": "Em breve",
|
||||
"configure_import": "Configurar importação",
|
||||
"configure_mapping": "Configurar mapeamento",
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector excluído com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"copied": "Copiado!",
|
||||
"copy": "Copiar",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"csv_columns": "Colunas CSV",
|
||||
"csv_files_only": "Apenas arquivos CSV",
|
||||
"csv_import": "Importação CSV",
|
||||
"delete_source": "Excluir fonte",
|
||||
"deselect_all": "Desmarcar tudo",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"drop_zone_path": "Caminho da zona de soltar",
|
||||
"edit_source_connection": "Editar conexão de origem",
|
||||
"element_selected": "<strong>{{count}}</strong> elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.",
|
||||
"email": "Email",
|
||||
"enable_auto_sync": "Ativar sincronização automática",
|
||||
"enter_name_for_source": "Digite um nome para esta origem",
|
||||
"every_15_minutes": "A cada 15 minutos",
|
||||
"every_30_minutes": "A cada 30 minutos",
|
||||
"every_5_minutes": "A cada 5 minutos",
|
||||
"every_hour": "A cada hora",
|
||||
"failed_to_copy": "Falha ao copiar",
|
||||
"failed_to_create_connector": "Falha ao criar conector",
|
||||
"failed_to_delete_connector": "Falha ao excluir conector",
|
||||
"failed_to_load_data": "Falha ao carregar dados",
|
||||
"failed_to_update_connector": "Falha ao atualizar conector",
|
||||
"feedback_date": "Data do feedback",
|
||||
"field": "campo",
|
||||
"fields": "campos",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"hub_feedback_record_fields": "Campos de registro de feedback do Hub",
|
||||
"iam_configuration_required": "Configuração IAM necessária",
|
||||
"iam_setup_instructions": "Adicione a função IAM do Formbricks à política do seu bucket S3 para habilitar o acesso.",
|
||||
"import_csv_data": "Importar dados CSV",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_elements": "{{count}} elementos",
|
||||
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
|
||||
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
|
||||
"no_surveys_found": "Nenhuma pesquisa encontrada neste ambiente",
|
||||
"optional": "Opcional",
|
||||
"or": "ou",
|
||||
"or_drag_and_drop": "ou arraste e solte",
|
||||
"process_new_files_description": "Processar automaticamente novos arquivos adicionados ao bucket",
|
||||
"processing_interval": "Intervalo de processamento",
|
||||
"region_ap_southeast_1": "Ásia-Pacífico (Singapura)",
|
||||
"region_eu_central_1": "UE (Frankfurt)",
|
||||
"region_eu_west_1": "UE (Irlanda)",
|
||||
"region_us_east_1": "Leste dos EUA (Norte da Virgínia)",
|
||||
"region_us_west_2": "Oeste dos EUA (Oregon)",
|
||||
"required": "Obrigatório",
|
||||
"s3_bucket_description": "Adicione arquivos CSV ao seu bucket S3 para importar feedback automaticamente. Os arquivos são processados a cada 15 minutos.",
|
||||
"s3_bucket_integration": "Integração com bucket S3",
|
||||
"save_changes": "Salvar alterações",
|
||||
"select_a_survey_to_see_elements": "Selecione uma pesquisa para ver seus elementos",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_elements": "Selecionar elementos",
|
||||
"select_questions": "Selecionar perguntas",
|
||||
"select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.",
|
||||
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que você deseja conectar:",
|
||||
"select_survey": "Selecionar pesquisa",
|
||||
"select_survey_and_questions": "Selecionar pesquisa e perguntas",
|
||||
"select_survey_questions_description": "Escolha quais perguntas da pesquisa devem criar FeedbackRecords.",
|
||||
"set_value": "definir valor",
|
||||
"setup_connection": "Configurar conexão",
|
||||
"showing_rows": "Mostrando 3 de {{count}} linhas",
|
||||
"slack_message": "Mensagem do Slack",
|
||||
"source_connect_csv_description": "Importar feedback de arquivos CSV",
|
||||
"source_connect_email_description": "Importar feedback de e-mail com mapeamento personalizado",
|
||||
"source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks",
|
||||
"source_connect_slack_description": "Conectar feedback de canais do Slack",
|
||||
"source_connect_webhook_description": "Receber feedback via webhook com mapeamento personalizado",
|
||||
"source_fields": "Campos de origem",
|
||||
"source_name": "Nome da origem",
|
||||
"source_type_cannot_be_changed": "O tipo de origem não pode ser alterado",
|
||||
"sources": "Origens",
|
||||
"status_active": "Ativa",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_paused": "Pausado",
|
||||
"suggest_mapping": "Sugerir mapeamento",
|
||||
"survey_has_no_elements": "Esta pesquisa não possui elementos de pergunta",
|
||||
"test_connection": "Testar conexão",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualize a configuração de mapeamento para esta fonte.",
|
||||
"upload_csv_data_description": "Faça upload de um arquivo CSV ou configure importações automatizadas do S3.",
|
||||
"upload_csv_file": "Fazer upload de arquivo CSV",
|
||||
"view_setup_guide": "Ver guia de configuração →",
|
||||
"webhook": "webhook",
|
||||
"yes_delete": "Sim, deletar"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave de API",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Terminar sessão",
|
||||
"look_and_feel": "Aparência e Sensação",
|
||||
"manage": "Gerir",
|
||||
"mappings": "Mapeamentos",
|
||||
"marketing": "Marketing",
|
||||
"member": "Membro",
|
||||
"members": "Membros",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Este questionário usa lógica de ramificação."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adicionar fonte de feedback",
|
||||
"add_source": "Adicionar fonte",
|
||||
"are_you_sure": "Tem a certeza?",
|
||||
"automated": "Automatizado",
|
||||
"aws_region": "Região AWS",
|
||||
"back": "Voltar",
|
||||
"cancel": "Cancelar",
|
||||
"change_file": "Alterar ficheiro",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"click_to_upload": "Clique para carregar",
|
||||
"coming_soon": "Em breve",
|
||||
"configure_import": "Configurar importação",
|
||||
"configure_mapping": "Configurar mapeamento",
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector eliminado com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"copied": "Copiado!",
|
||||
"copy": "Copiar",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"csv_columns": "Colunas CSV",
|
||||
"csv_files_only": "Apenas ficheiros CSV",
|
||||
"csv_import": "Importação CSV",
|
||||
"delete_source": "Eliminar fonte",
|
||||
"deselect_all": "Desselecionar tudo",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"drop_zone_path": "Caminho da zona de soltar",
|
||||
"edit_source_connection": "Editar ligação de origem",
|
||||
"element_selected": "<strong>{{count}}</strong> elemento selecionado. Cada resposta a este elemento criará um FeedbackRecord no Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> elementos selecionados. Cada resposta a estes elementos criará um FeedbackRecord no Hub.",
|
||||
"email": "Email",
|
||||
"enable_auto_sync": "Ativar sincronização automática",
|
||||
"enter_name_for_source": "Introduz um nome para esta origem",
|
||||
"every_15_minutes": "A cada 15 minutos",
|
||||
"every_30_minutes": "A cada 30 minutos",
|
||||
"every_5_minutes": "A cada 5 minutos",
|
||||
"every_hour": "A cada hora",
|
||||
"failed_to_copy": "Falha ao copiar",
|
||||
"failed_to_create_connector": "Falha ao criar conector",
|
||||
"failed_to_delete_connector": "Falha ao eliminar conector",
|
||||
"failed_to_load_data": "Falha ao carregar dados",
|
||||
"failed_to_update_connector": "Falha ao atualizar conector",
|
||||
"feedback_date": "Data do feedback",
|
||||
"field": "campo",
|
||||
"fields": "campos",
|
||||
"formbricks_surveys": "Pesquisas Formbricks",
|
||||
"hub_feedback_record_fields": "Campos de registo de feedback do Hub",
|
||||
"iam_configuration_required": "Configuração IAM necessária",
|
||||
"iam_setup_instructions": "Adiciona a função IAM do Formbricks à política do teu bucket S3 para ativar o acesso.",
|
||||
"import_csv_data": "Importar dados CSV",
|
||||
"load_sample_csv": "Carregar CSV de exemplo",
|
||||
"n_elements": "{{count}} elementos",
|
||||
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
|
||||
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
|
||||
"no_surveys_found": "Nenhum inquérito encontrado neste ambiente",
|
||||
"optional": "Opcional",
|
||||
"or": "ou",
|
||||
"or_drag_and_drop": "ou arraste e largue",
|
||||
"process_new_files_description": "Processar automaticamente novos ficheiros colocados no bucket",
|
||||
"processing_interval": "Intervalo de processamento",
|
||||
"region_ap_southeast_1": "Ásia-Pacífico (Singapura)",
|
||||
"region_eu_central_1": "UE (Frankfurt)",
|
||||
"region_eu_west_1": "UE (Irlanda)",
|
||||
"region_us_east_1": "EUA Leste (N. Virgínia)",
|
||||
"region_us_west_2": "EUA Oeste (Oregon)",
|
||||
"required": "Obrigatório",
|
||||
"s3_bucket_description": "Coloque ficheiros CSV no seu bucket S3 para importar automaticamente feedback. Os ficheiros são processados a cada 15 minutos.",
|
||||
"s3_bucket_integration": "Integração com bucket S3",
|
||||
"save_changes": "Guardar alterações",
|
||||
"select_a_survey_to_see_elements": "Selecione um inquérito para ver os seus elementos",
|
||||
"select_a_value": "Selecione um valor...",
|
||||
"select_all": "Selecionar tudo",
|
||||
"select_elements": "Selecionar elementos",
|
||||
"select_questions": "Selecionar perguntas",
|
||||
"select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.",
|
||||
"select_source_type_prompt": "Selecione o tipo de fonte de feedback que pretende conectar:",
|
||||
"select_survey": "Selecionar inquérito",
|
||||
"select_survey_and_questions": "Selecionar inquérito e perguntas",
|
||||
"select_survey_questions_description": "Escolha quais perguntas do inquérito devem criar FeedbackRecords.",
|
||||
"set_value": "definir valor",
|
||||
"setup_connection": "Configurar ligação",
|
||||
"showing_rows": "A mostrar 3 de {{count}} linhas",
|
||||
"slack_message": "Mensagem do Slack",
|
||||
"source_connect_csv_description": "Importar feedback de ficheiros CSV",
|
||||
"source_connect_email_description": "Importar feedback de email com mapeamento personalizado",
|
||||
"source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks",
|
||||
"source_connect_slack_description": "Conectar feedback de canais do Slack",
|
||||
"source_connect_webhook_description": "Receber feedback via webhook com mapeamento personalizado",
|
||||
"source_fields": "Campos da fonte",
|
||||
"source_name": "Nome da fonte",
|
||||
"source_type_cannot_be_changed": "O tipo de fonte não pode ser alterado",
|
||||
"sources": "Fontes",
|
||||
"status_active": "Ativa",
|
||||
"status_completed": "Concluído",
|
||||
"status_draft": "Rascunho",
|
||||
"status_paused": "Em pausa",
|
||||
"suggest_mapping": "Sugerir mapeamento",
|
||||
"survey_has_no_elements": "Este inquérito não tem elementos de pergunta",
|
||||
"test_connection": "Testar ligação",
|
||||
"unify_feedback": "Unificar feedback",
|
||||
"update_mapping_description": "Atualiza a configuração de mapeamento para esta origem.",
|
||||
"upload_csv_data_description": "Carrega um ficheiro CSV ou configura importações automáticas do S3.",
|
||||
"upload_csv_file": "Carregar ficheiro CSV",
|
||||
"view_setup_guide": "Ver guia de configuração →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Sim, eliminar"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Adicionar chave API",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Deconectare",
|
||||
"look_and_feel": "Aspect și Comportament",
|
||||
"manage": "Gestionați",
|
||||
"mappings": "Mapări",
|
||||
"marketing": "Marketing",
|
||||
"member": "Membru",
|
||||
"members": "Membri",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Acest sondaj folosește logică de ramificare."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Adaugă sursă de feedback",
|
||||
"add_source": "Adaugă sursă",
|
||||
"are_you_sure": "Ești sigur?",
|
||||
"automated": "Automatizat",
|
||||
"aws_region": "Regiune AWS",
|
||||
"back": "Înapoi",
|
||||
"cancel": "Anulează",
|
||||
"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",
|
||||
"coming_soon": "În curând",
|
||||
"configure_import": "Configurează importul",
|
||||
"configure_mapping": "Configurează maparea",
|
||||
"connector_created_successfully": "Conector creat cu succes",
|
||||
"connector_deleted_successfully": "Conector șters cu succes",
|
||||
"connector_updated_successfully": "Conector actualizat cu succes",
|
||||
"copied": "Copiat!",
|
||||
"copy": "Copiază",
|
||||
"create_mapping": "Creează mapare",
|
||||
"csv_columns": "Coloane CSV",
|
||||
"csv_files_only": "Doar fișiere CSV",
|
||||
"csv_import": "Import CSV",
|
||||
"delete_source": "Șterge sursa",
|
||||
"deselect_all": "Deselectează tot",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
"drop_field_or": "Trage câmpul sau",
|
||||
"drop_zone_path": "Cale zonă de plasare",
|
||||
"edit_source_connection": "Editează conexiunea sursei",
|
||||
"element_selected": "<strong>{{count}}</strong> element selectat. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> elemente selectate. Fiecare răspuns la aceste elemente va crea un FeedbackRecord în Hub.",
|
||||
"email": "Email",
|
||||
"enable_auto_sync": "Activează auto-sync",
|
||||
"enter_name_for_source": "Introdu un nume pentru această sursă",
|
||||
"every_15_minutes": "La fiecare 15 minute",
|
||||
"every_30_minutes": "La fiecare 30 de minute",
|
||||
"every_5_minutes": "La fiecare 5 minute",
|
||||
"every_hour": "La fiecare oră",
|
||||
"failed_to_copy": "Copiere eșuată",
|
||||
"failed_to_create_connector": "Crearea conectorului a eșuat",
|
||||
"failed_to_delete_connector": "Ștergerea conectorului a eșuat",
|
||||
"failed_to_load_data": "Încărcarea datelor a eșuat",
|
||||
"failed_to_update_connector": "Actualizarea conectorului a eșuat",
|
||||
"feedback_date": "Data feedbackului",
|
||||
"field": "câmp",
|
||||
"fields": "câmpuri",
|
||||
"formbricks_surveys": "Chestionare Formbricks",
|
||||
"hub_feedback_record_fields": "Câmpuri FeedbackRecord din Hub",
|
||||
"iam_configuration_required": "Configurare IAM necesară",
|
||||
"iam_setup_instructions": "Adaugă rolul Formbricks IAM în politica bucket-ului tău S3 pentru a permite accesul.",
|
||||
"import_csv_data": "Importă date CSV",
|
||||
"load_sample_csv": "Încarcă un CSV de exemplu",
|
||||
"n_elements": "{{count}} elemente",
|
||||
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
|
||||
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
|
||||
"no_surveys_found": "Nu s-au găsit sondaje în acest mediu",
|
||||
"optional": "Opțional",
|
||||
"or": "sau",
|
||||
"or_drag_and_drop": "sau trage și lasă aici",
|
||||
"process_new_files_description": "Procesează automat fișierele noi adăugate în bucket",
|
||||
"processing_interval": "Interval de procesare",
|
||||
"region_ap_southeast_1": "Asia Pacific (Singapore)",
|
||||
"region_eu_central_1": "UE (Frankfurt)",
|
||||
"region_eu_west_1": "UE (Irlanda)",
|
||||
"region_us_east_1": "SUA Est (N. Virginia)",
|
||||
"region_us_west_2": "SUA Vest (Oregon)",
|
||||
"required": "Obligatoriu",
|
||||
"s3_bucket_description": "Adaugă fișiere CSV în bucket-ul tău S3 pentru a importa automat feedback-ul. Fișierele sunt procesate la fiecare 15 minute.",
|
||||
"s3_bucket_integration": "Integrare S3 Bucket",
|
||||
"save_changes": "Salvează modificările",
|
||||
"select_a_survey_to_see_elements": "Selectează un sondaj pentru a vedea elementele",
|
||||
"select_a_value": "Selectează o valoare...",
|
||||
"select_all": "Selectează tot",
|
||||
"select_elements": "Selectează elemente",
|
||||
"select_questions": "Selectează întrebări",
|
||||
"select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.",
|
||||
"select_source_type_prompt": "Selectează tipul sursei de feedback pe care vrei să o conectezi:",
|
||||
"select_survey": "Selectează chestionar",
|
||||
"select_survey_and_questions": "Selectează chestionar și întrebări",
|
||||
"select_survey_questions_description": "Alege ce întrebări din chestionar vor crea FeedbackRecords.",
|
||||
"set_value": "setează valoare",
|
||||
"setup_connection": "Configurează conexiunea",
|
||||
"showing_rows": "Se afișează 3 din {{count}} rânduri",
|
||||
"slack_message": "Mesaj Slack",
|
||||
"source_connect_csv_description": "Importă feedback din fișiere CSV",
|
||||
"source_connect_email_description": "Importă feedback din email cu mapare personalizată",
|
||||
"source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks",
|
||||
"source_connect_slack_description": "Conectează feedback din canalele Slack",
|
||||
"source_connect_webhook_description": "Primește feedback prin webhook cu mapare personalizată",
|
||||
"source_fields": "Câmpuri sursă",
|
||||
"source_name": "Nume sursă",
|
||||
"source_type_cannot_be_changed": "Tipul sursei nu poate fi schimbat",
|
||||
"sources": "Surse",
|
||||
"status_active": "Activ",
|
||||
"status_completed": "Finalizat",
|
||||
"status_draft": "Schiță",
|
||||
"status_paused": "Pauzat",
|
||||
"suggest_mapping": "Sugerează mapare",
|
||||
"survey_has_no_elements": "Acest chestionar nu are elemente de întrebare",
|
||||
"test_connection": "Testează conexiunea",
|
||||
"unify_feedback": "Unify Feedback",
|
||||
"update_mapping_description": "Actualizează configurația de mapare pentru această sursă.",
|
||||
"upload_csv_data_description": "Încarcă un fișier CSV sau configurează importuri automate din S3.",
|
||||
"upload_csv_file": "Încarcă fișier CSV",
|
||||
"view_setup_guide": "Vezi ghidul de configurare →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Da, șterge"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Adaugă cheie API",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Выйти",
|
||||
"look_and_feel": "Внешний вид",
|
||||
"manage": "Управление",
|
||||
"mappings": "Сопоставления",
|
||||
"marketing": "Маркетинг",
|
||||
"member": "Участник",
|
||||
"members": "Участники",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "В этом опросе используется разветвлённая логика."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Добавить источник отзывов",
|
||||
"add_source": "Добавить источник",
|
||||
"are_you_sure": "Вы уверены?",
|
||||
"automated": "Автоматически",
|
||||
"aws_region": "Регион AWS",
|
||||
"back": "Назад",
|
||||
"cancel": "Отмена",
|
||||
"change_file": "Изменить файл",
|
||||
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
|
||||
"click_to_upload": "Кликните для загрузки",
|
||||
"coming_soon": "Скоро будет",
|
||||
"configure_import": "Настроить импорт",
|
||||
"configure_mapping": "Настроить сопоставление",
|
||||
"connector_created_successfully": "Коннектор успешно создан",
|
||||
"connector_deleted_successfully": "Коннектор успешно удалён",
|
||||
"connector_updated_successfully": "Коннектор успешно обновлён",
|
||||
"copied": "Скопировано!",
|
||||
"copy": "Копировать",
|
||||
"create_mapping": "Создать сопоставление",
|
||||
"csv_columns": "Столбцы CSV",
|
||||
"csv_files_only": "Только файлы CSV",
|
||||
"csv_import": "Импорт CSV",
|
||||
"delete_source": "Удалить источник",
|
||||
"deselect_all": "Снять выделение со всех",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
"drop_field_or": "Перетащи поле или",
|
||||
"drop_zone_path": "Путь зоны сброса",
|
||||
"edit_source_connection": "Редактировать подключение источника",
|
||||
"element_selected": "<strong>{{count}}</strong> элемент выбран. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.",
|
||||
"elements_selected": "<strong>{{count}}</strong> элемента выбрано. Каждый ответ на эти элементы создаст FeedbackRecord в Hub.",
|
||||
"email": "Email",
|
||||
"enable_auto_sync": "Включить авто-синхронизацию",
|
||||
"enter_name_for_source": "Введи имя для этого источника",
|
||||
"every_15_minutes": "Каждые 15 минут",
|
||||
"every_30_minutes": "Каждые 30 минут",
|
||||
"every_5_minutes": "Каждые 5 минут",
|
||||
"every_hour": "Каждый час",
|
||||
"failed_to_copy": "Не удалось скопировать",
|
||||
"failed_to_create_connector": "Не удалось создать коннектор",
|
||||
"failed_to_delete_connector": "Не удалось удалить коннектор",
|
||||
"failed_to_load_data": "Не удалось загрузить данные",
|
||||
"failed_to_update_connector": "Не удалось обновить коннектор",
|
||||
"feedback_date": "Дата отзыва",
|
||||
"field": "поле",
|
||||
"fields": "поля",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"hub_feedback_record_fields": "Поля FeedbackRecord в Hub",
|
||||
"iam_configuration_required": "Требуется настройка IAM",
|
||||
"iam_setup_instructions": "Добавь роль Formbricks IAM в политику своего S3-бакета для предоставления доступа.",
|
||||
"import_csv_data": "Импортировать данные CSV",
|
||||
"load_sample_csv": "Загрузить пример CSV",
|
||||
"n_elements": "{{count}} элементов",
|
||||
"no_source_fields_loaded": "Поля источника ещё не загружены",
|
||||
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
|
||||
"no_surveys_found": "В этой среде не найдено опросов",
|
||||
"optional": "Необязательно",
|
||||
"or": "или",
|
||||
"or_drag_and_drop": "или перетащите файл",
|
||||
"process_new_files_description": "Автоматически обрабатывать новые файлы, добавленные в бакет",
|
||||
"processing_interval": "Интервал обработки",
|
||||
"region_ap_southeast_1": "Азиатско-Тихоокеанский регион (Сингапур)",
|
||||
"region_eu_central_1": "ЕС (Франкфурт)",
|
||||
"region_eu_west_1": "ЕС (Ирландия)",
|
||||
"region_us_east_1": "США Восток (Северная Вирджиния)",
|
||||
"region_us_west_2": "США Запад (Орегон)",
|
||||
"required": "Обязательно",
|
||||
"s3_bucket_description": "Перемещайте файлы CSV в свой S3-бакет для автоматического импорта отзывов. Файлы обрабатываются каждые 15 минут.",
|
||||
"s3_bucket_integration": "Интеграция с S3-бакетом",
|
||||
"save_changes": "Сохранить изменения",
|
||||
"select_a_survey_to_see_elements": "Выберите опрос, чтобы увидеть его элементы",
|
||||
"select_a_value": "Выберите значение...",
|
||||
"select_all": "Выбрать все",
|
||||
"select_elements": "Выбрать элементы",
|
||||
"select_questions": "Выбрать вопросы",
|
||||
"select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.",
|
||||
"select_source_type_prompt": "Выберите тип источника отзывов, который хотите подключить:",
|
||||
"select_survey": "Выбрать опрос",
|
||||
"select_survey_and_questions": "Выбрать опрос и вопросы",
|
||||
"select_survey_questions_description": "Выберите, какие вопросы опроса должны создавать FeedbackRecords.",
|
||||
"set_value": "установить значение",
|
||||
"setup_connection": "Настроить подключение",
|
||||
"showing_rows": "Показано 3 из {{count}} строк",
|
||||
"slack_message": "Сообщение Slack",
|
||||
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
|
||||
"source_connect_email_description": "Импортировать отзывы из электронной почты с индивидуальным сопоставлением",
|
||||
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
|
||||
"source_connect_slack_description": "Подключить отзывы из каналов Slack",
|
||||
"source_connect_webhook_description": "Получать отзывы через webhook с индивидуальным сопоставлением",
|
||||
"source_fields": "Поля источника",
|
||||
"source_name": "Имя источника",
|
||||
"source_type_cannot_be_changed": "Тип источника нельзя изменить",
|
||||
"sources": "Источники",
|
||||
"status_active": "Активен",
|
||||
"status_completed": "Завершён",
|
||||
"status_draft": "Черновик",
|
||||
"status_paused": "Приостановлен",
|
||||
"suggest_mapping": "Предложить сопоставление",
|
||||
"survey_has_no_elements": "В этом опросе нет вопросов",
|
||||
"test_connection": "Проверить подключение",
|
||||
"unify_feedback": "Обратная связь Unify",
|
||||
"update_mapping_description": "Обнови настройки сопоставления для этого источника.",
|
||||
"upload_csv_data_description": "Загрузи CSV-файл или настрой автоматический импорт из S3.",
|
||||
"upload_csv_file": "Загрузить CSV-файл",
|
||||
"view_setup_guide": "Посмотреть инструкцию по настройке →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Да, удалить"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Добавить API-ключ",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "Logga ut",
|
||||
"look_and_feel": "Utseende",
|
||||
"manage": "Hantera",
|
||||
"mappings": "Mappningar",
|
||||
"marketing": "Marknadsföring",
|
||||
"member": "Medlem",
|
||||
"members": "Medlemmar",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "Denna enkät använder förgreningslogik."
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "Lägg till feedbackkälla",
|
||||
"add_source": "Lägg till källa",
|
||||
"are_you_sure": "Är du säker?",
|
||||
"automated": "Automatiserad",
|
||||
"aws_region": "AWS-region",
|
||||
"back": "Tillbaka",
|
||||
"cancel": "Avbryt",
|
||||
"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",
|
||||
"coming_soon": "Kommer snart",
|
||||
"configure_import": "Konfigurera import",
|
||||
"configure_mapping": "Konfigurera mappning",
|
||||
"connector_created_successfully": "Kopplingen skapades",
|
||||
"connector_deleted_successfully": "Kopplingen togs bort",
|
||||
"connector_updated_successfully": "Kopplingen uppdaterades",
|
||||
"copied": "Kopierat!",
|
||||
"copy": "Kopiera",
|
||||
"create_mapping": "Skapa mappning",
|
||||
"csv_columns": "CSV-kolumner",
|
||||
"csv_files_only": "Endast CSV-filer",
|
||||
"csv_import": "CSV-import",
|
||||
"delete_source": "Ta bort källa",
|
||||
"deselect_all": "Avmarkera alla",
|
||||
"drop_a_field_here": "Släpp ett fält här",
|
||||
"drop_field_or": "Släpp fält eller",
|
||||
"drop_zone_path": "Släppzonens sökväg",
|
||||
"edit_source_connection": "Redigera källans anslutning",
|
||||
"element_selected": "<strong>{{count}}</strong> element valt. Varje svar på dessa element skapar en FeedbackRecord i Hubben.",
|
||||
"elements_selected": "<strong>{{count}}</strong> element valda. Varje svar på dessa element skapar en FeedbackRecord i Hubben.",
|
||||
"email": "E-post",
|
||||
"enable_auto_sync": "Aktivera auto-sync",
|
||||
"enter_name_for_source": "Ange ett namn för denna källa",
|
||||
"every_15_minutes": "Var 15:e minut",
|
||||
"every_30_minutes": "Var 30:e minut",
|
||||
"every_5_minutes": "Var 5:e minut",
|
||||
"every_hour": "Varje timme",
|
||||
"failed_to_copy": "Kunde inte kopiera",
|
||||
"failed_to_create_connector": "Kunde inte skapa connector",
|
||||
"failed_to_delete_connector": "Kunde inte ta bort connector",
|
||||
"failed_to_load_data": "Kunde inte ladda data",
|
||||
"failed_to_update_connector": "Kunde inte uppdatera connector",
|
||||
"feedback_date": "Feedbackdatum",
|
||||
"field": "fält",
|
||||
"fields": "fält",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"hub_feedback_record_fields": "Fält för Hub Feedback Record",
|
||||
"iam_configuration_required": "IAM-konfiguration krävs",
|
||||
"iam_setup_instructions": "Lägg till Formbricks IAM-roll i din S3-bucketpolicy för att aktivera åtkomst.",
|
||||
"import_csv_data": "Importera CSV-data",
|
||||
"load_sample_csv": "Ladda exempel-CSV",
|
||||
"n_elements": "{{count}} element",
|
||||
"no_source_fields_loaded": "Inga källfält har laddats än",
|
||||
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
|
||||
"no_surveys_found": "Inga enkäter hittades i denna miljö",
|
||||
"optional": "Valfritt",
|
||||
"or": "eller",
|
||||
"or_drag_and_drop": "eller dra och släpp",
|
||||
"process_new_files_description": "Bearbeta nya filer som släpps i bucketen automatiskt",
|
||||
"processing_interval": "Bearbetningsintervall",
|
||||
"region_ap_southeast_1": "Asien och Stillahavsområdet (Singapore)",
|
||||
"region_eu_central_1": "EU (Frankfurt)",
|
||||
"region_eu_west_1": "EU (Irland)",
|
||||
"region_us_east_1": "USA Öst (N. Virginia)",
|
||||
"region_us_west_2": "USA Väst (Oregon)",
|
||||
"required": "Obligatoriskt",
|
||||
"s3_bucket_description": "Släpp CSV-filer i din S3-bucket för att automatiskt importera feedback. Filer bearbetas var 15:e minut.",
|
||||
"s3_bucket_integration": "S3-bucket-integration",
|
||||
"save_changes": "Spara ändringar",
|
||||
"select_a_survey_to_see_elements": "Välj en enkät för att se dess element",
|
||||
"select_a_value": "Välj ett värde...",
|
||||
"select_all": "Välj alla",
|
||||
"select_elements": "Välj element",
|
||||
"select_questions": "Välj frågor",
|
||||
"select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.",
|
||||
"select_source_type_prompt": "Välj vilken typ av feedbackkälla du vill ansluta:",
|
||||
"select_survey": "Välj enkät",
|
||||
"select_survey_and_questions": "Välj enkät & frågor",
|
||||
"select_survey_questions_description": "Välj vilka enkätfrågor som ska skapa FeedbackRecords.",
|
||||
"set_value": "ange värde",
|
||||
"setup_connection": "Ställ in anslutning",
|
||||
"showing_rows": "Visar 3 av {{count}} rader",
|
||||
"slack_message": "Slack-meddelande",
|
||||
"source_connect_csv_description": "Importera feedback från CSV-filer",
|
||||
"source_connect_email_description": "Importera feedback från e-post med anpassad mappning",
|
||||
"source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter",
|
||||
"source_connect_slack_description": "Anslut feedback från Slack-kanaler",
|
||||
"source_connect_webhook_description": "Ta emot feedback via webhook med anpassad mappning",
|
||||
"source_fields": "Källfält",
|
||||
"source_name": "Källnamn",
|
||||
"source_type_cannot_be_changed": "Källtyp kan inte ändras",
|
||||
"sources": "Källor",
|
||||
"status_active": "Aktiv",
|
||||
"status_completed": "Slutförd",
|
||||
"status_draft": "Utkast",
|
||||
"status_paused": "Pausad",
|
||||
"suggest_mapping": "Föreslå mappning",
|
||||
"survey_has_no_elements": "Den här enkäten har inga frågeelement",
|
||||
"test_connection": "Testa anslutning",
|
||||
"unify_feedback": "Samla feedback",
|
||||
"update_mapping_description": "Uppdatera mappningskonfigurationen för den här källan.",
|
||||
"upload_csv_data_description": "Ladda upp en CSV-fil eller ställ in automatiska S3-importer.",
|
||||
"upload_csv_file": "Ladda upp CSV-fil",
|
||||
"view_setup_guide": "Visa installationsguide →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "Ja, ta bort"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "Lägg till API-nyckel",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "退出登录",
|
||||
"look_and_feel": "外观 & 感觉",
|
||||
"manage": "管理",
|
||||
"mappings": "映射",
|
||||
"marketing": "市场营销",
|
||||
"member": "成员",
|
||||
"members": "成员",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "此调查 使用 分支逻辑。"
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "添加反馈来源",
|
||||
"add_source": "添加来源",
|
||||
"are_you_sure": "你确定吗?",
|
||||
"automated": "自动化",
|
||||
"aws_region": "AWS 区域",
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"change_file": "更换文件",
|
||||
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
|
||||
"click_to_upload": "点击上传",
|
||||
"coming_soon": "即将推出",
|
||||
"configure_import": "配置导入",
|
||||
"configure_mapping": "配置映射",
|
||||
"connector_created_successfully": "连接器创建成功",
|
||||
"connector_deleted_successfully": "连接器删除成功",
|
||||
"connector_updated_successfully": "连接器更新成功",
|
||||
"copied": "已复制!",
|
||||
"copy": "复制",
|
||||
"create_mapping": "创建映射",
|
||||
"csv_columns": "CSV 列",
|
||||
"csv_files_only": "仅限 CSV 文件",
|
||||
"csv_import": "CSV 导入",
|
||||
"delete_source": "删除来源",
|
||||
"deselect_all": "取消全选",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
"drop_field_or": "拖放字段或",
|
||||
"drop_zone_path": "拖放区域路径",
|
||||
"edit_source_connection": "编辑源连接",
|
||||
"element_selected": "已选择 <strong>{{count}}</strong> 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。",
|
||||
"elements_selected": "已选择 <strong>{{count}}</strong> 个元素。每个元素的反馈都会在 Hub 中创建一个 FeedbackRecord。",
|
||||
"email": "邮箱",
|
||||
"enable_auto_sync": "启用 auto 同步",
|
||||
"enter_name_for_source": "为此来源输入名称",
|
||||
"every_15_minutes": "每 15 分钟",
|
||||
"every_30_minutes": "每 30 分钟",
|
||||
"every_5_minutes": "每 5 分钟",
|
||||
"every_hour": "每小时",
|
||||
"failed_to_copy": "复制失败",
|
||||
"failed_to_create_connector": "创建连接器失败",
|
||||
"failed_to_delete_connector": "删除连接器失败",
|
||||
"failed_to_load_data": "加载数据失败",
|
||||
"failed_to_update_connector": "更新连接器失败",
|
||||
"feedback_date": "反馈日期",
|
||||
"field": "字段",
|
||||
"fields": "字段",
|
||||
"formbricks_surveys": "Formbricks Surveys",
|
||||
"hub_feedback_record_fields": "Hub 反馈记录字段",
|
||||
"iam_configuration_required": "需要 IAM 配置",
|
||||
"iam_setup_instructions": "将 Formbricks IAM 角色添加到你的 S3 bucket 策略中以启用访问权限。",
|
||||
"import_csv_data": "导入 CSV 数据",
|
||||
"load_sample_csv": "加载示例 CSV",
|
||||
"n_elements": "{{count}} 个元素",
|
||||
"no_source_fields_loaded": "尚未加载源字段",
|
||||
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
|
||||
"no_surveys_found": "此环境下未找到调查",
|
||||
"optional": "可选",
|
||||
"or": "或",
|
||||
"or_drag_and_drop": "或拖放",
|
||||
"process_new_files_description": "自动处理存储桶中新上传的文件",
|
||||
"processing_interval": "处理间隔",
|
||||
"region_ap_southeast_1": "亚太地区(新加坡)",
|
||||
"region_eu_central_1": "欧盟(法兰克福)",
|
||||
"region_eu_west_1": "欧盟(爱尔兰)",
|
||||
"region_us_east_1": "美国东部(弗吉尼亚北部)",
|
||||
"region_us_west_2": "美国西部(俄勒冈)",
|
||||
"required": "必填",
|
||||
"s3_bucket_description": "将 CSV 文件放入你的 S3 存储桶,即可自动导入反馈。文件每 15 分钟处理一次。",
|
||||
"s3_bucket_integration": "S3 存储桶集成",
|
||||
"save_changes": "保存更改",
|
||||
"select_a_survey_to_see_elements": "选择一个调查以查看其元素",
|
||||
"select_a_value": "选择一个值...",
|
||||
"select_all": "全选",
|
||||
"select_elements": "选择元素",
|
||||
"select_questions": "选择问题",
|
||||
"select_source_type_description": "请选择你想要连接的反馈来源类型。",
|
||||
"select_source_type_prompt": "请选择你想要连接的反馈来源类型:",
|
||||
"select_survey": "选择调查",
|
||||
"select_survey_and_questions": "选择调查和问题",
|
||||
"select_survey_questions_description": "选择哪些调查问题会创建反馈记录。",
|
||||
"set_value": "设置值",
|
||||
"setup_connection": "设置连接",
|
||||
"showing_rows": "显示 {{count}} 行中的 3 行",
|
||||
"slack_message": "Slack 消息",
|
||||
"source_connect_csv_description": "从 CSV 文件导入反馈",
|
||||
"source_connect_email_description": "通过自定义映射从邮箱导入反馈",
|
||||
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
|
||||
"source_connect_slack_description": "连接来自 Slack 频道的反馈",
|
||||
"source_connect_webhook_description": "通过自定义映射使用 webhook 接收反馈",
|
||||
"source_fields": "来源字段",
|
||||
"source_name": "来源名称",
|
||||
"source_type_cannot_be_changed": "来源类型无法更改",
|
||||
"sources": "来源",
|
||||
"status_active": "已激活",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_paused": "已暂停",
|
||||
"suggest_mapping": "建议映射",
|
||||
"survey_has_no_elements": "此调查没有问题元素",
|
||||
"test_connection": "测试连接",
|
||||
"unify_feedback": "统一反馈",
|
||||
"update_mapping_description": "更新此来源的映射配置。",
|
||||
"upload_csv_data_description": "上传 CSV 文件或设置自动 S3 导入。",
|
||||
"upload_csv_file": "上传 CSV 文件",
|
||||
"view_setup_guide": "查看设置指南 →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "是的,删除"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "添加 API 密钥",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"logout": "登出",
|
||||
"look_and_feel": "外觀與風格",
|
||||
"manage": "管理",
|
||||
"mappings": "對應關係",
|
||||
"marketing": "行銷",
|
||||
"member": "成員",
|
||||
"members": "成員",
|
||||
@@ -2038,6 +2039,114 @@
|
||||
"uses_branching_logic": "此問卷使用分支邏輯。"
|
||||
}
|
||||
},
|
||||
"unify": {
|
||||
"add_feedback_source": "新增回饋來源",
|
||||
"add_source": "新增來源",
|
||||
"are_you_sure": "您確定嗎?",
|
||||
"automated": "自動化",
|
||||
"aws_region": "AWS 區域",
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"change_file": "更換檔案",
|
||||
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
|
||||
"click_to_upload": "點擊以上傳",
|
||||
"coming_soon": "即將推出",
|
||||
"configure_import": "設定匯入",
|
||||
"configure_mapping": "設定對應關係",
|
||||
"connector_created_successfully": "連接器建立成功",
|
||||
"connector_deleted_successfully": "連接器刪除成功",
|
||||
"connector_updated_successfully": "連接器更新成功",
|
||||
"copied": "已複製!",
|
||||
"copy": "複製",
|
||||
"create_mapping": "建立對應關係",
|
||||
"csv_columns": "CSV 欄位",
|
||||
"csv_files_only": "僅限 CSV 檔案",
|
||||
"csv_import": "CSV 匯入",
|
||||
"delete_source": "刪除來源",
|
||||
"deselect_all": "取消全選",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
"drop_field_or": "拖曳欄位或",
|
||||
"drop_zone_path": "拖曳區路徑",
|
||||
"edit_source_connection": "編輯來源連線",
|
||||
"element_selected": "已選取 <strong>{{count}}</strong> 個元素。每個元素的回應都會在 Hub 中建立一筆 FeedbackRecord。",
|
||||
"elements_selected": "已選取 <strong>{{count}}</strong> 個元素。每個元素的回應都會在 Hub 中建立一筆 FeedbackRecord。",
|
||||
"email": "電子郵件",
|
||||
"enable_auto_sync": "啟用 auto 同步",
|
||||
"enter_name_for_source": "請輸入此來源的名稱",
|
||||
"every_15_minutes": "每 15 分鐘",
|
||||
"every_30_minutes": "每 30 分鐘",
|
||||
"every_5_minutes": "每 5 分鐘",
|
||||
"every_hour": "每小時",
|
||||
"failed_to_copy": "複製失敗",
|
||||
"failed_to_create_connector": "建立連接器失敗",
|
||||
"failed_to_delete_connector": "刪除連接器失敗",
|
||||
"failed_to_load_data": "載入資料失敗",
|
||||
"failed_to_update_connector": "更新連接器失敗",
|
||||
"feedback_date": "回饋日期",
|
||||
"field": "欄位",
|
||||
"fields": "欄位",
|
||||
"formbricks_surveys": "Formbricks 問卷",
|
||||
"hub_feedback_record_fields": "Hub 回饋紀錄欄位",
|
||||
"iam_configuration_required": "需要 IAM 設定",
|
||||
"iam_setup_instructions": "請將 Formbricks IAM 角色加入你的 S3 bucket policy 以啟用存取權限。",
|
||||
"import_csv_data": "匯入 CSV 資料",
|
||||
"load_sample_csv": "載入範例 CSV",
|
||||
"n_elements": "{{count}} 個元素",
|
||||
"no_source_fields_loaded": "尚未載入來源欄位",
|
||||
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
|
||||
"no_surveys_found": "此環境中找不到問卷",
|
||||
"optional": "選填",
|
||||
"or": "或",
|
||||
"or_drag_and_drop": "或拖曳檔案",
|
||||
"process_new_files_description": "自動處理丟到 bucket 裡的新檔案",
|
||||
"processing_interval": "處理間隔",
|
||||
"region_ap_southeast_1": "亞太區(新加坡)",
|
||||
"region_eu_central_1": "歐盟(法蘭克福)",
|
||||
"region_eu_west_1": "歐盟(愛爾蘭)",
|
||||
"region_us_east_1": "美國東部(維吉尼亞北部)",
|
||||
"region_us_west_2": "美國西部(奧勒岡)",
|
||||
"required": "必填",
|
||||
"s3_bucket_description": "將 CSV 檔案放到你的 S3 bucket,就能自動匯入回饋。檔案每 15 分鐘處理一次。",
|
||||
"s3_bucket_integration": "S3 Bucket 整合",
|
||||
"save_changes": "儲存變更",
|
||||
"select_a_survey_to_see_elements": "請選擇問卷以查看其元素",
|
||||
"select_a_value": "請選擇一個值...",
|
||||
"select_all": "全選",
|
||||
"select_elements": "選取元素",
|
||||
"select_questions": "選取問題",
|
||||
"select_source_type_description": "請選擇你想要連接的回饋來源類型。",
|
||||
"select_source_type_prompt": "請選擇你想要連接的回饋來源類型:",
|
||||
"select_survey": "選擇問卷",
|
||||
"select_survey_and_questions": "選擇問卷與問題",
|
||||
"select_survey_questions_description": "請選擇哪些問卷問題要建立 FeedbackRecords。",
|
||||
"set_value": "設定值",
|
||||
"setup_connection": "設定連線",
|
||||
"showing_rows": "顯示 {{count}} 筆資料中的 3 筆",
|
||||
"slack_message": "Slack 訊息",
|
||||
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
|
||||
"source_connect_email_description": "從電子郵件匯入回饋並自訂對應",
|
||||
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
|
||||
"source_connect_slack_description": "連接來自 Slack 頻道的回饋",
|
||||
"source_connect_webhook_description": "透過 webhook 並自訂對應接收回饋",
|
||||
"source_fields": "來源欄位",
|
||||
"source_name": "來源名稱",
|
||||
"source_type_cannot_be_changed": "來源類型無法變更",
|
||||
"sources": "來源",
|
||||
"status_active": "啟用中",
|
||||
"status_completed": "已完成",
|
||||
"status_draft": "草稿",
|
||||
"status_paused": "已暫停",
|
||||
"suggest_mapping": "建議對應",
|
||||
"survey_has_no_elements": "此問卷沒有任何問題",
|
||||
"test_connection": "測試連線",
|
||||
"unify_feedback": "整合回饋",
|
||||
"update_mapping_description": "更新此來源的對應設定。",
|
||||
"upload_csv_data_description": "上傳 CSV 檔案或設定自動 S3 匯入。",
|
||||
"upload_csv_file": "上傳 CSV 檔案",
|
||||
"view_setup_guide": "查看設定指南 →",
|
||||
"webhook": "Webhook",
|
||||
"yes_delete": "確定刪除"
|
||||
},
|
||||
"workspace": {
|
||||
"api_keys": {
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
|
||||
@@ -79,7 +79,7 @@ export const ProjectConfigNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "unify",
|
||||
label: "Unify Feedback",
|
||||
label: t("environments.unify.unify_feedback"),
|
||||
icon: <Cable className="h-5 w-5" />,
|
||||
href: `/environments/${environmentId}/workspace/unify`,
|
||||
current: pathname?.includes("/unify"),
|
||||
|
||||
@@ -38,7 +38,6 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const isSelectingRef = React.useRef(false);
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
// Track if changes are user-initiated (not from value prop)
|
||||
@@ -194,12 +193,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onBlur={(e) => {
|
||||
// Don't close if we're selecting an option
|
||||
if (!isSelectingRef.current) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
@@ -230,18 +224,12 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isSelectingRef.current = true;
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (disabled) return;
|
||||
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||
setSelected((prev) => [...prev, option]);
|
||||
setInputValue("");
|
||||
// Reset the flag after a short delay to allow the selection to complete
|
||||
setTimeout(() => {
|
||||
isSelectingRef.current = false;
|
||||
setOpen(false);
|
||||
}, 100);
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
{option.label}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."ChartType" AS ENUM ('area', 'bar', 'line', 'pie', 'big_number', 'big_number_total', 'table', 'funnel', 'map');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."DashboardStatus" AS ENUM ('draft', 'published');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."WidgetType" AS ENUM ('chart', 'markdown', 'header', 'divider');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."Chart" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "public"."ChartType" NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"query" JSONB NOT NULL DEFAULT '{}',
|
||||
"config" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdBy" TEXT,
|
||||
|
||||
CONSTRAINT "Chart_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."Dashboard" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "public"."DashboardStatus" NOT NULL DEFAULT 'draft',
|
||||
"projectId" TEXT NOT NULL,
|
||||
"createdBy" TEXT,
|
||||
|
||||
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."DashboardWidget" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"dashboardId" TEXT NOT NULL,
|
||||
"type" "public"."WidgetType" NOT NULL,
|
||||
"title" TEXT,
|
||||
"chartId" TEXT,
|
||||
"content" JSONB,
|
||||
"layout" JSONB NOT NULL DEFAULT '{"x":0,"y":0,"w":4,"h":3}',
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "DashboardWidget_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Chart_projectId_created_at_idx" ON "public"."Chart"("projectId", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Chart_projectId_name_key" ON "public"."Chart"("projectId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Dashboard_projectId_created_at_idx" ON "public"."Dashboard"("projectId", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Dashboard_projectId_status_idx" ON "public"."Dashboard"("projectId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Dashboard_projectId_name_key" ON "public"."Dashboard"("projectId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DashboardWidget_dashboardId_order_idx" ON "public"."DashboardWidget"("dashboardId", "order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."Chart" ADD CONSTRAINT "Chart_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."Dashboard" ADD CONSTRAINT "Dashboard_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."DashboardWidget" ADD CONSTRAINT "DashboardWidget_chartId_fkey" FOREIGN KEY ("chartId") REFERENCES "public"."Chart"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -649,8 +649,6 @@ model Project {
|
||||
logo Json?
|
||||
projectTeams ProjectTeam[]
|
||||
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
|
||||
charts Chart[]
|
||||
dashboards Dashboard[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
@@ -1100,114 +1098,3 @@ model ConnectorFieldMapping {
|
||||
@@unique([connectorId, sourceFieldId, targetFieldId])
|
||||
@@index([connectorId])
|
||||
}
|
||||
|
||||
enum ChartType {
|
||||
area
|
||||
bar
|
||||
line
|
||||
pie
|
||||
big_number
|
||||
big_number_total
|
||||
table
|
||||
funnel
|
||||
map
|
||||
}
|
||||
|
||||
enum DashboardStatus {
|
||||
draft
|
||||
published
|
||||
}
|
||||
|
||||
enum WidgetType {
|
||||
chart
|
||||
markdown
|
||||
header
|
||||
divider
|
||||
}
|
||||
|
||||
/// Represents a chart/visualization that can be used in multiple dashboards.
|
||||
/// Charts are reusable components that query analytics data.
|
||||
///
|
||||
/// @property id - Unique identifier for the chart
|
||||
/// @property name - Display name of the chart
|
||||
/// @property type - Type of visualization (bar, line, pie, etc.)
|
||||
/// @property project - The project this chart belongs to
|
||||
/// @property query - Cube.js query configuration (JSON)
|
||||
/// @property config - Chart-specific configuration (colors, labels, etc.)
|
||||
/// @property createdBy - User who created the chart
|
||||
/// @property dashboards - Dashboards that use this chart
|
||||
model Chart {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
type ChartType
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
/// [ChartQuery] - Cube.js query configuration
|
||||
query Json @default("{}")
|
||||
/// [ChartConfig] - Visualization configuration (colors, labels, formatting)
|
||||
config Json @default("{}")
|
||||
createdBy String?
|
||||
widgets DashboardWidget[]
|
||||
|
||||
@@unique([projectId, name])
|
||||
@@index([projectId, createdAt])
|
||||
}
|
||||
|
||||
/// Represents a dashboard containing multiple widgets/charts.
|
||||
/// Dashboards aggregate analytics insights at the project level.
|
||||
///
|
||||
/// @property id - Unique identifier for the dashboard
|
||||
/// @property name - Display name of the dashboard
|
||||
/// @property description - Optional description
|
||||
/// @property status - Whether the dashboard is draft or published
|
||||
/// @property project - The project this dashboard belongs to
|
||||
/// @property widgets - Charts and other widgets on this dashboard
|
||||
/// @property createdBy - User who created the dashboard
|
||||
model Dashboard {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
description String?
|
||||
status DashboardStatus @default(draft)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
createdBy String?
|
||||
widgets DashboardWidget[]
|
||||
|
||||
@@unique([projectId, name])
|
||||
@@index([projectId, createdAt])
|
||||
@@index([projectId, status])
|
||||
}
|
||||
|
||||
/// Represents a widget on a dashboard (chart, markdown, header, etc.).
|
||||
/// Widgets are positioned using a grid layout system.
|
||||
///
|
||||
/// @property id - Unique identifier for the widget
|
||||
/// @property dashboard - The dashboard this widget belongs to
|
||||
/// @property type - Type of widget (chart, markdown, header, divider)
|
||||
/// @property title - Optional title for the widget
|
||||
/// @property chart - Reference to chart if type is "chart"
|
||||
/// @property content - Content for markdown/header widgets
|
||||
/// @property layout - Grid layout configuration (x, y, width, height)
|
||||
/// @property order - Display order within the dashboard
|
||||
model DashboardWidget {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
dashboardId String
|
||||
type WidgetType
|
||||
title String?
|
||||
chart Chart? @relation(fields: [chartId], references: [id], onDelete: SetNull)
|
||||
chartId String?
|
||||
/// [WidgetContent] - Content for markdown/header widgets
|
||||
content Json?
|
||||
/// [WidgetLayout] - Grid layout: { x, y, w, h }
|
||||
layout Json @default("{\"x\":0,\"y\":0,\"w\":4,\"h\":3}")
|
||||
order Int @default(0)
|
||||
|
||||
@@index([dashboardId, order])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user