This commit is contained in:
pandeymangg
2026-02-18 17:07:23 +05:30
parent 3233dec57f
commit e90b8228d0
36 changed files with 1922 additions and 1832 deletions

View File

@@ -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} />;
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"

View File

@@ -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}

View File

@@ -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 })}

View File

@@ -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">

View File

@@ -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

View File

@@ -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();

View File

@@ -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",
},
});
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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",

View File

@@ -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": "Lets 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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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キーを追加",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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-ключ",

View File

@@ -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",

View File

@@ -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 密钥",

View File

@@ -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 金鑰",

View File

@@ -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"),

View File

@@ -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}

View File

@@ -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;

View File

@@ -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])
}