Compare commits

...

3 Commits

Author SHA1 Message Date
Johannes
590c85d1ca add sources UI 2026-01-28 08:54:52 +04:00
Harsh Bhat
39c99baaac feat: Add mock data and UI for taxanomy & knowledge 2026-01-27 16:12:19 +04:00
Harsh Bhat
238b2adf3f feat: Unify POC hackathon 2026-01-27 14:58:20 +04:00
39 changed files with 4199 additions and 5 deletions

View File

@@ -2,6 +2,7 @@
import {
ArrowUpRightIcon,
BarChartIcon,
ChevronRightIcon,
Cog,
LogOutIcon,
@@ -9,6 +10,7 @@ import {
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
ShapesIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
@@ -99,7 +101,7 @@ export const MainNavigation = ({
const mainNavigation = useMemo(
() => [
{
name: t("common.surveys"),
name: "Ask",
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
@@ -107,12 +109,24 @@ export const MainNavigation = ({
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
name: "Distribute",
icon: UserIcon,
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
},
{
name: t("common.configuration"),
name: "Unify",
href: `/environments/${environment.id}/workspace/unify`,
icon: ShapesIcon,
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
},
{
name: "Analyze",
href: `/environments/${environment.id}/workspace/analyze`,
icon: BarChartIcon,
isActive: pathname?.includes("/workspace/analyze"),
},
{
name: "Configure",
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
@@ -185,7 +199,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -133,6 +133,11 @@ export const ProjectBreadcrumb = ({
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
{
id: "unify",
label: "Unify Feedback",
href: `/environments/${currentEnvironmentId}/workspace/unify`,
},
];
if (!currentProject) {

View File

@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { CreateDashboardModal } from "./create-dashboard-modal";
import { DashboardsTable } from "./dashboards-table";
import { TDashboard } from "./types";
interface AnalyzeSectionProps {
environmentId: string;
}
export function AnalyzeSection({ environmentId }: AnalyzeSectionProps) {
const [dashboards, setDashboards] = useState<TDashboard[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const handleCreateDashboard = (dashboard: TDashboard) => {
setDashboards((prev) => [dashboard, ...prev]);
};
const handleDashboardClick = (dashboard: TDashboard) => {
// TODO: Navigate to dashboard detail view
console.log("Dashboard clicked:", dashboard);
};
return (
<PageContentWrapper>
<PageHeader
pageTitle="Analyze"
cta={
<CreateDashboardModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateDashboard={handleCreateDashboard}
/>
}
/>
<div className="space-y-6">
<DashboardsTable dashboards={dashboards} onDashboardClick={handleDashboardClick} />
</div>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { TDashboard } from "./types";
interface CreateDashboardModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateDashboard: (dashboard: TDashboard) => void;
}
export function CreateDashboardModal({ open, onOpenChange, onCreateDashboard }: CreateDashboardModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const handleCreate = () => {
if (!name.trim()) return;
const newDashboard: TDashboard = {
id: crypto.randomUUID(),
name: name.trim(),
description: description.trim() || undefined,
widgetCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
onCreateDashboard(newDashboard);
resetForm();
onOpenChange(false);
};
const resetForm = () => {
setName("");
setDescription("");
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
Create dashboard
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Dashboard</DialogTitle>
<DialogDescription>
Create a new dashboard to visualize and analyze your unified feedback data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="dashboardName">Name</Label>
<Input
id="dashboardName"
placeholder="e.g., Product Feedback Overview"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dashboardDescription">Description (optional)</Label>
<Input
id="dashboardDescription"
placeholder="e.g., Weekly overview of customer feedback trends"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!name.trim()}>
Create dashboard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { LayoutDashboardIcon } from "lucide-react";
import { TDashboard } from "./types";
interface DashboardsTableProps {
dashboards: TDashboard[];
onDashboardClick: (dashboard: TDashboard) => void;
}
export function DashboardsTable({ dashboards, onDashboardClick }: DashboardsTableProps) {
if (dashboards.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-200 bg-white py-16">
<LayoutDashboardIcon className="h-12 w-12 text-slate-300" />
<p className="mt-4 text-sm font-medium text-slate-600">No dashboards yet</p>
<p className="mt-1 text-sm text-slate-500">Create your first dashboard to start analyzing feedback</p>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white">
{/* Table Header */}
<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-5 pl-6">Name</div>
<div className="col-span-3 hidden text-center sm:block">Widgets</div>
<div className="col-span-2 hidden text-center sm:block">Updated</div>
<div className="col-span-2 hidden pr-6 text-right sm:block">Created</div>
</div>
{/* Table Rows */}
<div className="divide-y divide-slate-100">
{dashboards.map((dashboard) => (
<div
key={dashboard.id}
role="button"
tabIndex={0}
className="grid h-16 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={() => onDashboardClick(dashboard)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onDashboardClick(dashboard);
}
}}>
{/* Name Column */}
<div className="col-span-5 flex items-center gap-3 pl-4">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
<LayoutDashboardIcon className="h-4 w-4 text-slate-600" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900">{dashboard.name}</span>
{dashboard.description && (
<span className="truncate text-xs text-slate-500">{dashboard.description}</span>
)}
</div>
</div>
{/* Widgets Column */}
<div className="col-span-3 hidden items-center justify-center text-sm text-slate-600 sm:flex">
{dashboard.widgetCount} {dashboard.widgetCount === 1 ? "widget" : "widgets"}
</div>
{/* Updated Column */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{formatDistanceToNow(dashboard.updatedAt, { addSuffix: true })}
</div>
{/* Created Column */}
<div className="col-span-2 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
{formatDistanceToNow(dashboard.createdAt, { addSuffix: true })}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { AnalyzeSection } from "./AnalyzeSection";
export { CreateDashboardModal } from "./create-dashboard-modal";
export { DashboardsTable } from "./dashboards-table";
export type { TDashboard } from "./types";

View File

@@ -0,0 +1,8 @@
export interface TDashboard {
id: string;
name: string;
description?: string;
widgetCount: number;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,10 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { AnalyzeSection } from "./components";
export default async function AnalyzePage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <AnalyzeSection environmentId={params.environmentId} />;
}

View File

@@ -0,0 +1,39 @@
"use client";
import { usePathname } from "next/navigation";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
environmentId: string;
activeId?: string;
loading?: boolean;
}
export const UnifyConfigNavigation = ({
environmentId,
activeId: activeIdProp,
loading,
}: UnifyConfigNavigationProps) => {
const pathname = usePathname();
const activeId =
activeIdProp ??
(pathname?.includes("/unify/sources")
? "sources"
: pathname?.includes("/unify/knowledge")
? "knowledge"
: pathname?.includes("/unify/taxonomy")
? "taxonomy"
: "controls");
const baseHref = `/environments/${environmentId}/workspace/unify`;
const navigation = [
{ id: "controls", label: "Controls", href: `${baseHref}/controls` },
{ id: "sources", label: "Sources", href: `${baseHref}/sources` },
{ id: "knowledge", label: "Knowledge", href: `${baseHref}/knowledge` },
{ id: "taxonomy", label: "Taxonomy", href: `${baseHref}/taxonomy` },
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};

View File

@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { Badge } from "@/modules/ui/components/badge";
import { Label } from "@/modules/ui/components/label";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
// Common languages for the base language selector
const COMMON_LANGUAGES = [
{ code: "en", label: "English" },
{ code: "de", label: "German" },
{ code: "fr", label: "French" },
{ code: "es", label: "Spanish" },
{ code: "pt", label: "Portuguese" },
{ code: "it", label: "Italian" },
{ code: "nl", label: "Dutch" },
{ code: "pl", label: "Polish" },
{ code: "ru", label: "Russian" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
{ code: "zh-Hans", label: "Chinese (Simplified)" },
{ code: "zh-Hant", label: "Chinese (Traditional)" },
{ code: "ar", label: "Arabic" },
{ code: "hi", label: "Hindi" },
{ code: "tr", label: "Turkish" },
{ code: "sv", label: "Swedish" },
{ code: "no", label: "Norwegian" },
{ code: "da", label: "Danish" },
{ code: "fi", label: "Finnish" },
];
interface ControlsSectionProps {
environmentId: string;
}
export function ControlsSection({ environmentId }: ControlsSectionProps) {
const [baseLanguage, setBaseLanguage] = useState("en");
return (
<PageContentWrapper>
<PageHeader pageTitle="Unify Feedback">
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="max-w-4xl">
<SettingsCard
title="Feedback Controls"
description="Configure how feedback is processed and consolidated across all sources.">
<div className="space-y-6">
{/* Base Language Setting */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="baseLanguage">Base Language</Label>
<Badge text="AI" type="gray" size="tiny" />
</div>
<p className="text-sm text-slate-500">
All feedback will be consolidated and analyzed in this language. Feedback in other languages
will be automatically translated.
</p>
<div className="w-64">
<Select value={baseLanguage} onValueChange={setBaseLanguage}>
<SelectTrigger id="baseLanguage">
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
{COMMON_LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</SettingsCard>
</div>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1 @@
export { ControlsSection } from "./ControlsSection";

View File

@@ -0,0 +1,10 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ControlsSection } from "./components";
export default async function UnifyControlsPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <ControlsSection environmentId={params.environmentId} />;
}

View File

@@ -0,0 +1,256 @@
"use client";
import { FileTextIcon, LinkIcon, PlusIcon, StickyNoteIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import type { KnowledgeItem } from "../types";
const DOC_EXTENSIONS: TAllowedFileExtension[] = ["pdf", "doc", "docx", "txt", "csv"];
const MAX_DOC_SIZE_MB = 5;
interface AddKnowledgeModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (item: KnowledgeItem) => void;
environmentId: string;
isStorageConfigured: boolean;
}
export function AddKnowledgeModal({
open,
onOpenChange,
onAdd,
environmentId,
isStorageConfigured,
}: AddKnowledgeModalProps) {
const [linkUrl, setLinkUrl] = useState("");
const [linkTitle, setLinkTitle] = useState("");
const [noteContent, setNoteContent] = useState("");
const [uploadedDocUrl, setUploadedDocUrl] = useState<string | null>(null);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const resetForm = () => {
setLinkUrl("");
setLinkTitle("");
setNoteContent("");
setUploadedDocUrl(null);
setUploadedFileName(null);
};
const handleDocUpload = async (files: File[]) => {
if (!isStorageConfigured) {
toast.error("File storage is not configured.");
return;
}
const file = files[0];
if (!file) return;
setIsUploading(true);
setUploadedDocUrl(null);
setUploadedFileName(null);
const result = await handleFileUpload(file, environmentId, DOC_EXTENSIONS);
setIsUploading(false);
if (result.error) {
toast.error("Upload failed. Please try again.");
return;
}
setUploadedDocUrl(result.url);
setUploadedFileName(file.name);
toast.success("Document uploaded. Click Add to save.");
};
const handleAddLink = () => {
if (!linkUrl.trim()) {
toast.error("Please enter a URL.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "link",
title: linkTitle.trim() || undefined,
url: linkUrl.trim(),
size: linkUrl.trim().length * 100, // Simulated size for links
createdAt: now,
indexedAt: now, // Links are indexed immediately
});
resetForm();
onOpenChange(false);
toast.success("Link added.");
};
const handleAddNote = () => {
if (!noteContent.trim()) {
toast.error("Please enter some text.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "note",
content: noteContent.trim(),
size: new Blob([noteContent.trim()]).size,
createdAt: now,
indexedAt: now, // Notes are indexed immediately
});
resetForm();
onOpenChange(false);
toast.success("Note added.");
};
const handleAddFile = () => {
if (!uploadedDocUrl) {
toast.error("Please upload a document first.");
return;
}
const now = new Date();
onAdd({
id: crypto.randomUUID(),
type: "file",
title: uploadedFileName ?? undefined,
fileUrl: uploadedDocUrl,
fileName: uploadedFileName ?? undefined,
size: Math.floor(Math.random() * 500000) + 10000, // Simulated file size (10KB - 500KB)
createdAt: now,
indexedAt: undefined, // Files take time to index - will show as "Pending"
});
resetForm();
onOpenChange(false);
toast.success("Document added.");
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) handleDocUpload(files);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
Add knowledge
<PlusIcon className="ml-2 size-4" />
</Button>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) resetForm();
onOpenChange(o);
}}>
<DialogContent className="sm:max-w-lg" disableCloseOnOutsideClick>
<DialogHeader>
<PlusIcon className="size-5 text-slate-600" />
<DialogTitle>Add knowledge</DialogTitle>
<DialogDescription>Add knowledge via a link, document upload, or a text note.</DialogDescription>
</DialogHeader>
<DialogBody>
<Tabs defaultValue="link" className="w-full">
<TabsList width="fill" className="mb-4 w-full">
<TabsTrigger value="link" icon={<LinkIcon className="size-4" />}>
Link
</TabsTrigger>
<TabsTrigger value="upload" icon={<FileTextIcon className="size-4" />}>
Upload doc
</TabsTrigger>
<TabsTrigger value="note" icon={<StickyNoteIcon className="size-4" />}>
Note
</TabsTrigger>
</TabsList>
<TabsContent value="link" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="link-title">Title (optional)</Label>
<Input
id="link-title"
placeholder="e.g. Product docs"
value={linkTitle}
onChange={(e) => setLinkTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
type="url"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
/>
</div>
<Button type="button" onClick={handleAddLink} size="sm">
Add link
</Button>
</TabsContent>
<TabsContent value="upload" className="space-y-4">
<Uploader
id="knowledge-doc-modal"
name="knowledge-doc-modal"
uploaderClassName="h-32 w-full"
allowedFileExtensions={DOC_EXTENSIONS}
multiple={false}
handleUpload={handleDocUpload}
handleDrop={handleDrop}
handleDragOver={handleDragOver}
isStorageConfigured={isStorageConfigured}
/>
<p className="text-xs text-slate-500">PDF, Word, text, or CSV. Max {MAX_DOC_SIZE_MB} MB.</p>
{isUploading && <p className="text-sm text-slate-600">Uploading</p>}
{uploadedDocUrl && (
<p className="text-sm text-slate-700">
Ready: <span className="font-medium">{uploadedFileName ?? uploadedDocUrl}</span>
</p>
)}
<Button type="button" onClick={handleAddFile} size="sm" disabled={!uploadedDocUrl}>
Add document
</Button>
</TabsContent>
<TabsContent value="note" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="knowledge-note-modal">Note</Label>
<textarea
id="knowledge-note-modal"
rows={5}
placeholder="Paste or type knowledge content here..."
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)}
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
/>
</div>
<Button type="button" onClick={handleAddNote} size="sm">
Add note
</Button>
</TabsContent>
</Tabs>
</DialogBody>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import type { KnowledgeItem } from "../types";
import { AddKnowledgeModal } from "./AddKnowledgeModal";
import { KnowledgeTable } from "./KnowledgeTable";
interface KnowledgeSectionProps {
environmentId: string;
isStorageConfigured: boolean;
}
export function KnowledgeSection({ environmentId, isStorageConfigured }: KnowledgeSectionProps) {
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const handleDeleteItem = (itemId: string) => {
setItems((prev) => prev.filter((item) => item.id !== itemId));
};
return (
<PageContentWrapper>
<PageHeader
pageTitle="Unify Feedback"
cta={
<AddKnowledgeModal
open={modalOpen}
onOpenChange={setModalOpen}
onAdd={(item) => {
setItems((prev) => [...prev, item]);
setModalOpen(false);
}}
environmentId={environmentId}
isStorageConfigured={isStorageConfigured}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<KnowledgeTable items={items} onDeleteItem={handleDeleteItem} />
</div>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { FileTextIcon, LinkIcon, MoreHorizontalIcon, StickyNoteIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { type KnowledgeItem, formatFileSize } from "../types";
interface KnowledgeTableProps {
items: KnowledgeItem[];
onDeleteItem?: (itemId: string) => void;
}
function getTypeIcon(type: KnowledgeItem["type"]) {
switch (type) {
case "link":
return <LinkIcon className="size-4 text-slate-500" />;
case "file":
return <FileTextIcon className="size-4 text-slate-500" />;
case "note":
return <StickyNoteIcon className="size-4 text-slate-500" />;
default:
return <FileTextIcon className="size-4 text-slate-500" />;
}
}
function getTypeLabel(type: KnowledgeItem["type"]) {
switch (type) {
case "link":
return "Link";
case "file":
return "Document";
case "note":
return "Note";
default:
return type;
}
}
function getTitleOrPreview(item: KnowledgeItem): string {
if (item.title) return item.title;
if (item.type === "link" && item.url) return item.url;
if (item.type === "file" && item.fileName) return item.fileName;
if (item.type === "note" && item.content) {
return item.content.length > 60 ? `${item.content.slice(0, 60)}` : item.content;
}
return "—";
}
export function KnowledgeTable({ items, onDeleteItem }: KnowledgeTableProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
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-5 pl-6">Name</div>
<div className="col-span-2 hidden text-center sm:block">Type</div>
<div className="col-span-2 hidden text-center sm:block">Size</div>
<div className="col-span-2 hidden text-center sm:block">Indexed At</div>
<div className="col-span-1 pr-6 text-right">Actions</div>
</div>
{items.length === 0 ? (
<p className="py-12 text-center text-sm text-slate-400">
No knowledge yet. Add a link, upload a document, or add a note.
</p>
) : (
<div className="divide-y divide-slate-100">
{items.map((item) => (
<div
key={item.id}
className="grid h-12 min-h-12 grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50">
{/* Name */}
<div className="col-span-5 flex items-center gap-3 pl-6">
{getTypeIcon(item.type)}
<div className="flex flex-col overflow-hidden">
<div className="truncate text-sm font-medium text-slate-900">{getTitleOrPreview(item)}</div>
{item.type === "link" && item.url && item.title && (
<div className="truncate text-xs text-slate-500">{item.url}</div>
)}
</div>
</div>
{/* Type */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
{getTypeLabel(item.type)}
</div>
{/* Size */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{formatFileSize(item.size)}
</div>
{/* Indexed At */}
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
{item.indexedAt ? (
<span title={format(item.indexedAt, "PPpp")}>
{formatDistanceToNow(item.indexedAt, { addSuffix: true }).replace("about ", "")}
</span>
) : (
<span className="text-slate-400">Pending</span>
)}
</div>
{/* Actions */}
<div className="col-span-1 flex items-center justify-end pr-6">
<DropdownMenu
open={openMenuId === item.id}
onOpenChange={(open) => setOpenMenuId(open ? item.id : null)}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600 focus:bg-red-50 focus:text-red-700"
onClick={() => {
onDeleteItem?.(item.id);
setOpenMenuId(null);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { KnowledgeSection } from "./components/KnowledgeSection";
export default async function UnifyKnowledgePage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return (
<KnowledgeSection
environmentId={params.environmentId}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
);
}

View File

@@ -0,0 +1,22 @@
export type KnowledgeItemType = "link" | "note" | "file";
export interface KnowledgeItem {
id: string;
type: KnowledgeItemType;
title?: string;
url?: string;
content?: string;
fileUrl?: string;
fileName?: string;
size?: number; // Size in bytes
createdAt: Date;
indexedAt?: Date;
}
// Format file size to human readable string
export function formatFileSize(bytes?: number): string {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
redirect(`/environments/${params.environmentId}/workspace/unify/controls`);
}

View File

@@ -0,0 +1,396 @@
"use client";
import { PlusIcon, SparklesIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { CsvSourceUI } from "./csv-source-ui";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { MappingUI } from "./mapping-ui";
import { SourceTypeSelector } from "./source-type-selector";
import {
AI_SUGGESTED_MAPPINGS,
EMAIL_SOURCE_FIELDS,
FEEDBACK_RECORD_FIELDS,
MOCK_FORMBRICKS_SURVEYS,
SAMPLE_CSV_COLUMNS,
SAMPLE_WEBHOOK_PAYLOAD,
TCreateSourceStep,
TFieldMapping,
TSourceConnection,
TSourceField,
TSourceType,
parseCSVColumnsToFields,
parsePayloadToFields,
} from "./types";
interface CreateSourceModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateSource: (source: TSourceConnection) => void;
}
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 }: CreateSourceModalProps) {
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 [selectedQuestionIds, setSelectedQuestionIds] = useState<string[]>([]);
const resetForm = () => {
setCurrentStep("selectType");
setSelectedType(null);
setSourceName("");
setMappings([]);
setSourceFields([]);
setDeriveFromAttachments(false);
setSelectedSurveyId(null);
setSelectedQuestionIds([]);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleNextStep = () => {
if (currentStep === "selectType" && selectedType && selectedType !== "slack") {
if (selectedType === "formbricks") {
// For Formbricks, use the survey name if selected
const selectedSurvey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === selectedSurveyId);
setSourceName(
selectedSurvey ? `${selectedSurvey.name} Connection` : getDefaultSourceName(selectedType)
);
} else {
setSourceName(getDefaultSourceName(selectedType));
}
setCurrentStep("mapping");
}
};
// Formbricks handlers
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleQuestionToggle = (questionId: string) => {
setSelectedQuestionIds((prev) =>
prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId]
);
};
const handleSelectAllQuestions = (surveyId: string) => {
const survey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === surveyId);
if (survey) {
setSelectedQuestionIds(survey.questions.map((q) => q.id));
}
};
const handleDeselectAllQuestions = () => {
setSelectedQuestionIds([]);
};
const handleBack = () => {
if (currentStep === "mapping") {
setCurrentStep("selectType");
setMappings([]);
setSourceFields([]);
}
};
const handleCreateSource = () => {
if (!selectedType || !sourceName.trim()) return;
// Check if all required fields are mapped
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id)
);
if (!allRequiredMapped) {
// For now, we'll allow creating without all required fields for POC
console.warn("Not all required fields are mapped");
}
const newSource: TSourceConnection = {
id: crypto.randomUUID(),
name: sourceName.trim(),
type: selectedType,
mappings,
createdAt: new Date(),
updatedAt: new Date(),
};
onCreateSource(newSource);
resetForm();
onOpenChange(false);
};
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
);
// Formbricks validation - need survey and at least one question selected
const isFormbricksValid =
selectedType === "formbricks" && selectedSurveyId && selectedQuestionIds.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);
}
setSourceFields(fields);
};
const handleSuggestMapping = () => {
if (!selectedType) return;
const suggestions = AI_SUGGESTED_MAPPINGS[selectedType];
if (!suggestions) return;
const newMappings: TFieldMapping[] = [];
// Add field mappings from source fields
for (const sourceField of sourceFields) {
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
if (suggestedTarget) {
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
if (targetExists) {
newMappings.push({
sourceFieldId: sourceField.id,
targetFieldId: suggestedTarget,
});
}
}
}
// 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) {
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
newMappings.push({
targetFieldId,
staticValue,
});
}
}
}
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
<PlusIcon className="ml-2 h-4 w-4" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{currentStep === "selectType"
? "Add Feedback Source"
: selectedType === "formbricks"
? "Select Survey & Questions"
: selectedType === "csv"
? "Import CSV Data"
: "Configure Mapping"}
</DialogTitle>
<DialogDescription>
{currentStep === "selectType"
? "Select the type of feedback source you want to connect."
: selectedType === "formbricks"
? "Choose which survey questions should create FeedbackRecords."
: selectedType === "csv"
? "Upload a CSV file or set up automated S3 imports."
: "Map source fields to Hub Feedback Record fields."}
</DialogDescription>
</DialogHeader>
<div className="py-4">
{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>
<Input
id="sourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
selectedSurveyId={selectedSurveyId}
selectedQuestionIds={selectedQuestionIds}
onSurveySelect={handleSurveySelect}
onQuestionToggle={handleQuestionToggle}
onSelectAllQuestions={handleSelectAllQuestions}
onDeselectAllQuestions={handleDeselectAllQuestions}
/>
</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>
<Input
id="sourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<CsvSourceUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
onSourceFieldsChange={setSourceFields}
onLoadSampleCSV={handleLoadSourceFields}
/>
</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>
<Input
id="sourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
{/* Action buttons above scrollable area */}
<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>
<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>
)}
</div>
<DialogFooter>
{currentStep === "mapping" && (
<Button variant="outline" onClick={handleBack}>
Back
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType || selectedType === "slack"}>
{selectedType === "formbricks"
? "Select questions"
: selectedType === "csv"
? "Configure import"
: "Create mapping"}
</Button>
) : (
<Button
onClick={handleCreateSource}
disabled={
!sourceName.trim() ||
(selectedType === "formbricks"
? !isFormbricksValid
: selectedType === "csv"
? !isCsvValid
: !allRequiredMapped)
}>
Setup connection
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,327 @@
"use client";
import {
ArrowUpFromLineIcon,
CloudIcon,
CopyIcon,
FolderIcon,
RefreshCwIcon,
SettingsIcon,
} from "lucide-react";
import { useState } from "react";
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,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { Switch } from "@/modules/ui/components/switch";
import { MappingUI } from "./mapping-ui";
import { TFieldMapping, TSourceField } from "./types";
interface CsvSourceUIProps {
sourceFields: TSourceField[];
mappings: TFieldMapping[];
onMappingsChange: (mappings: TFieldMapping[]) => void;
onSourceFieldsChange: (fields: TSourceField[]) => void;
onLoadSampleCSV: () => void;
}
export function CsvSourceUI({
sourceFields,
mappings,
onMappingsChange,
onSourceFieldsChange,
onLoadSampleCSV,
}: CsvSourceUIProps) {
const [csvFile, setCsvFile] = useState<File | null>(null);
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
const [showMapping, setShowMapping] = useState(false);
const [s3AutoSync, setS3AutoSync] = useState(false);
const [s3Copied, setS3Copied] = useState(false);
// Mock S3 bucket details
const s3BucketName = "formbricks-feedback-imports";
const s3Path = `s3://${s3BucketName}/feedback/incoming/`;
const handleCopyS3Path = () => {
navigator.clipboard.writeText(s3Path);
setS3Copied(true);
setTimeout(() => setS3Copied(false), 2000);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target?.files?.[0];
if (file) {
processCSVFile(file);
}
};
const processCSVFile = (file: File) => {
if (!file.name.endsWith(".csv")) {
return;
}
setCsvFile(file);
const reader = new FileReader();
reader.onload = (e) => {
const csv = e.target?.result as string;
const lines = csv.split("\n").slice(0, 6); // Preview first 5 rows + header
const preview = lines.map((line) => line.split(",").map((cell) => cell.trim()));
setCsvPreview(preview);
// Extract columns and create source fields
if (preview.length > 0) {
const headers = preview[0];
const fields: TSourceField[] = headers.map((header) => ({
id: header,
name: header,
type: "string",
sampleValue: preview[1]?.[headers.indexOf(header)] || "",
}));
onSourceFieldsChange(fields);
setShowMapping(true);
}
};
reader.readAsText(file);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) {
processCSVFile(file);
}
};
const handleLoadSample = () => {
onLoadSampleCSV();
setShowMapping(true);
};
// If mapping is shown, show the mapping UI
if (showMapping && sourceFields.length > 0) {
return (
<div className="space-y-4">
{/* File info bar */}
{csvFile && (
<div className="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-2">
<div className="flex items-center gap-2">
<FolderIcon className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">{csvFile.name}</span>
<Badge text={`${csvPreview.length - 1} rows`} type="success" size="tiny" />
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCsvFile(null);
setCsvPreview([]);
setShowMapping(false);
onSourceFieldsChange([]);
}}>
Change file
</Button>
</div>
)}
{/* CSV Preview Table */}
{csvPreview.length > 0 && (
<div className="overflow-hidden rounded-lg border border-slate-200">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
{csvPreview[0]?.map((header, i) => (
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.slice(1, 4).map((row, rowIndex) => (
<tr key={rowIndex} className="border-t border-slate-100">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="px-3 py-2 text-slate-600">
{cell || <span className="text-slate-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</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
</div>
)}
</div>
)}
{/* Mapping UI */}
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={onMappingsChange}
sourceType="csv"
/>
</div>
);
}
// Upload and S3 setup UI
return (
<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>
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
<label
htmlFor="csv-file-upload"
className="flex cursor-pointer flex-col items-center justify-center"
onDragOver={handleDragOver}
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
</p>
<p className="mt-1 text-xs text-slate-400">CSV files only</p>
<input
type="file"
id="csv-file-upload"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</label>
</div>
<div className="flex justify-between">
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
Load sample CSV
</Button>
</div>
</div>
{/* 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>
<div className="h-px flex-1 bg-slate-200" />
</div>
{/* S3 Integration Section */}
<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" />
</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>
{/* S3 Path Display */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">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"}
</Button>
</div>
</div>
{/* S3 Settings */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs">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>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">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>
</SelectContent>
</Select>
</div>
</div>
{/* 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-xs text-slate-500">
Automatically process new files dropped in the bucket
</span>
</div>
<Switch checked={s3AutoSync} onCheckedChange={setS3AutoSync} />
</div>
{/* IAM Instructions */}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<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="mt-1 text-xs text-amber-700">
Add the Formbricks IAM role to your S3 bucket policy to enable access.{" "}
<button type="button" className="font-medium underline hover:no-underline">
View setup guide
</button>
</p>
</div>
</div>
</div>
{/* Test Connection */}
<div className="flex justify-end">
<Button variant="outline" size="sm" className="gap-2">
<RefreshCwIcon className="h-4 w-4" />
Test connection
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,299 @@
"use client";
import {
FileSpreadsheetIcon,
GlobeIcon,
MailIcon,
MessageSquareIcon,
SparklesIcon,
WebhookIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { MappingUI } from "./mapping-ui";
import {
AI_SUGGESTED_MAPPINGS,
EMAIL_SOURCE_FIELDS,
FEEDBACK_RECORD_FIELDS,
SAMPLE_CSV_COLUMNS,
SAMPLE_WEBHOOK_PAYLOAD,
TFieldMapping,
TSourceConnection,
TSourceField,
TSourceType,
parseCSVColumnsToFields,
parsePayloadToFields,
} from "./types";
interface EditSourceModalProps {
source: TSourceConnection | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdateSource: (source: TSourceConnection) => void;
onDeleteSource: (sourceId: string) => void;
}
function getSourceIcon(type: TSourceType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
case "webhook":
return <WebhookIcon className="h-5 w-5 text-slate-500" />;
case "email":
return <MailIcon className="h-5 w-5 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
case "slack":
return <MessageSquareIcon className="h-5 w-5 text-slate-500" />;
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
}
function getSourceTypeLabel(type: TSourceType) {
switch (type) {
case "formbricks":
return "Formbricks Surveys";
case "webhook":
return "Webhook";
case "email":
return "Email";
case "csv":
return "CSV Import";
case "slack":
return "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,
onOpenChange,
onUpdateSource,
onDeleteSource,
}: EditSourceModalProps) {
const [sourceName, setSourceName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
useEffect(() => {
if (source) {
setSourceName(source.name);
setMappings(source.mappings);
setSourceFields(getInitialSourceFields(source.type));
setDeriveFromAttachments(false);
}
}, [source]);
const resetForm = () => {
setSourceName("");
setMappings([]);
setSourceFields([]);
setShowDeleteConfirm(false);
setDeriveFromAttachments(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleUpdateSource = () => {
if (!source || !sourceName.trim()) return;
const updatedSource: TSourceConnection = {
...source,
name: sourceName.trim(),
mappings,
updatedAt: new Date(),
};
onUpdateSource(updatedSource);
handleOpenChange(false);
};
const handleDeleteSource = () => {
if (!source) return;
onDeleteSource(source.id);
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];
if (!suggestions) return;
const newMappings: TFieldMapping[] = [];
for (const sourceField of sourceFields) {
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
if (suggestedTarget) {
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
if (targetExists) {
newMappings.push({
sourceFieldId: sourceField.id,
targetFieldId: suggestedTarget,
});
}
}
}
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
if (targetExists) {
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
newMappings.push({
targetFieldId,
staticValue,
});
}
}
}
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>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Source Type Display */}
<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>
</div>
</div>
{/* Source Name */}
<div className="space-y-2">
<Label htmlFor="editSourceName">Source Name</Label>
<Input
id="editSourceName"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="Enter a name for this source"
/>
</div>
{/* Action buttons above scrollable area */}
<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>
</div>
<DialogFooter className="flex justify-between">
<div>
{showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600">Are you sure?</span>
<Button variant="destructive" size="sm" onClick={handleDeleteSource}>
Yes, delete
</Button>
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
Delete source
</Button>
)}
</div>
<Button onClick={handleUpdateSource} disabled={!sourceName.trim()}>
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import {
CheckCircle2Icon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleIcon,
FileTextIcon,
MessageSquareTextIcon,
StarIcon,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/modules/ui/components/badge";
import {
MOCK_FORMBRICKS_SURVEYS,
TFormbricksSurvey,
TFormbricksSurveyQuestion,
getQuestionTypeLabel,
} from "./types";
interface FormbricksSurveySelectorProps {
selectedSurveyId: string | null;
selectedQuestionIds: string[];
onSurveySelect: (surveyId: string | null) => void;
onQuestionToggle: (questionId: string) => void;
onSelectAllQuestions: (surveyId: string) => void;
onDeselectAllQuestions: () => void;
}
function getQuestionIcon(type: TFormbricksSurveyQuestion["type"]) {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
case "rating":
case "nps":
case "csat":
return <StarIcon className="h-4 w-4 text-amber-500" />;
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
}
function getStatusBadge(status: TFormbricksSurvey["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({
selectedSurveyId,
selectedQuestionIds,
onSurveySelect,
onQuestionToggle,
onSelectAllQuestions,
onDeselectAllQuestions,
}: FormbricksSurveySelectorProps) {
const [expandedSurveyId, setExpandedSurveyId] = useState<string | null>(null);
const selectedSurvey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === selectedSurveyId);
const handleSurveyClick = (survey: TFormbricksSurvey) => {
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);
onDeselectAllQuestions();
setExpandedSurveyId(survey.id);
}
};
const allQuestionsSelected =
selectedSurvey && selectedQuestionIds.length === selectedSurvey.questions.length;
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>
<div className="space-y-2">
{MOCK_FORMBRICKS_SURVEYS.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
const isExpanded = expandedSurveyId === survey.id;
return (
<div key={survey.id}>
<button
type="button"
onClick={() => handleSurveyClick(survey)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-brand-dark bg-slate-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-slate-600" />
) : (
<ChevronRightIcon className="h-4 w-4 text-slate-600" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
{getStatusBadge(survey.status)}
</div>
<p className="text-xs text-slate-500">
{survey.questions.length} questions · {survey.responseCount.toLocaleString()} responses
</p>
</div>
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
</button>
</div>
);
})}
</div>
</div>
{/* Right: Question Selection */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">Select Questions</h4>
{selectedSurvey && (
<button
type="button"
onClick={() =>
allQuestionsSelected ? onDeselectAllQuestions() : onSelectAllQuestions(selectedSurvey.id)
}
className="text-xs text-slate-500 hover:text-slate-700">
{allQuestionsSelected ? "Deselect all" : "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 questions</p>
</div>
) : (
<div className="space-y-2">
{selectedSurvey.questions.map((question) => {
const isSelected = selectedQuestionIds.includes(question.id);
return (
<button
key={question.id}
type="button"
onClick={() => onQuestionToggle(question.id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
isSelected
? "border-green-300 bg-green-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${
isSelected ? "bg-green-500 text-white" : "border border-slate-300 bg-white"
}`}>
{isSelected && <CheckIcon className="h-3 w-3" />}
</div>
<div className="flex items-center gap-2">{getQuestionIcon(question.type)}</div>
<div className="flex-1">
<p className="text-sm text-slate-900">{question.headline}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{getQuestionTypeLabel(question.type)}</span>
{question.required && (
<span className="text-xs text-red-500">
<CircleIcon className="inline h-1.5 w-1.5 fill-current" /> Required
</span>
)}
</div>
</div>
</button>
);
})}
{selectedQuestionIds.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>{selectedQuestionIds.length}</strong> question
{selectedQuestionIds.length !== 1 ? "s" : ""} selected. Each response to these questions
will create a FeedbackRecord in the Hub.
</p>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export { CreateSourceModal } from "./create-source-modal";
export { CsvSourceUI } from "./csv-source-ui";
export { EditSourceModal } from "./edit-source-modal";
export { FormbricksSurveySelector } from "./formbricks-survey-selector";
export { MappingUI } from "./mapping-ui";
export { SourcesSection } from "./sources-page-client";
export { SourcesTable } from "./sources-table";
export { SourcesTableDataRow } from "./sources-table-data-row";
export { SourceTypeSelector } from "./source-type-selector";
export * from "./types";

View File

@@ -0,0 +1,305 @@
"use client";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TFieldMapping, TSourceField, TTargetField } from "./types";
interface DraggableSourceFieldProps {
field: TSourceField;
isMapped: boolean;
}
export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: field.id,
data: field,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isDragging
? "border-brand-dark bg-slate-100 opacity-50"
: isMapped
? "border-green-300 bg-green-50 text-green-800"
: "border-slate-200 bg-white hover:border-slate-300"
}`}>
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
<div className="flex-1 truncate">
<span className="font-medium">{field.name}</span>
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
</div>
{field.sampleValue && (
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
)}
</div>
);
}
interface DroppableTargetFieldProps {
field: TTargetField;
mappedSourceField: TSourceField | null;
mapping: TFieldMapping | null;
onRemoveMapping: () => void;
onStaticValueChange: (value: string) => void;
isOver?: boolean;
}
export function DroppableTargetField({
field,
mappedSourceField,
mapping,
onRemoveMapping,
onStaticValueChange,
isOver,
}: DroppableTargetFieldProps) {
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
id: field.id,
data: field,
});
const [isEditingStatic, setIsEditingStatic] = useState(false);
const [customValue, setCustomValue] = useState("");
const isActive = isOver || isOverCurrent;
const hasMapping = mappedSourceField || mapping?.staticValue;
// Handle enum field type - show dropdown
if (field.type === "enum" && field.enumValues) {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
mapping?.staticValue ? "border-green-300 bg-green-50" : "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<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>
</div>
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
<SelectTrigger className="h-8 w-full bg-white">
<SelectValue placeholder="Select a value..." />
</SelectTrigger>
<SelectContent>
{field.enumValues.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
// Handle string fields - allow drag & drop OR static value
if (field.type === "string") {
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col gap-1">
<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>}
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= "{mapping.staticValue}"
</span>
<button
type="button"
onClick={onRemoveMapping}
className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* Show input for entering static value when editing */}
{isEditingStatic && !hasMapping && (
<div className="flex items-center gap-1">
<Input
type="text"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={
field.exampleStaticValues ? `e.g., ${field.exampleStaticValues[0]}` : "Enter value..."
}
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
setIsEditingStatic(false);
}
if (e.key === "Escape") {
setCustomValue("");
setIsEditingStatic(false);
}
}}
/>
<button
type="button"
onClick={() => {
if (customValue.trim()) {
onStaticValueChange(customValue.trim());
setCustomValue("");
}
setIsEditingStatic(false);
}}
className="rounded p-1 text-slate-500 hover:bg-slate-200">
<ChevronDownIcon className="h-3 w-3" />
</button>
</div>
)}
{/* 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>
<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
</button>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.slice(0, 3).map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{val}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}
// Helper to get display label for static values
const getStaticValueLabel = (value: string) => {
if (value === "$now") return "Feedback date";
return value;
};
// Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.)
const hasDefaultMapping = mappedSourceField || mapping?.staticValue;
return (
<div
ref={setNodeRef}
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
isActive
? "border-brand-dark bg-slate-100"
: hasDefaultMapping
? "border-green-300 bg-green-50"
: "border-dashed border-slate-300 bg-slate-50"
}`}>
<div className="flex flex-1 flex-col">
<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">({field.type})</span>
</div>
{/* Show mapped source field */}
{mappedSourceField && !mapping?.staticValue && (
<div className="mt-1 flex items-center gap-1">
<span className="text-xs text-green-700"> {mappedSourceField.name}</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
<XIcon className="h-3 w-3 text-green-600" />
</button>
</div>
)}
{/* Show static value */}
{mapping?.staticValue && !mappedSourceField && (
<div className="mt-1 flex items-center gap-1">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
= {getStaticValueLabel(mapping.staticValue)}
</span>
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
<XIcon className="h-3 w-3 text-blue-600" />
</button>
</div>
)}
{/* 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>
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
<>
<span className="text-xs text-slate-300">|</span>
{field.exampleStaticValues.map((val) => (
<button
key={val}
type="button"
onClick={() => onStaticValueChange(val)}
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
{getStaticValueLabel(val)}
</button>
))}
</>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,232 @@
"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 { DraggableSourceField, DroppableTargetField } from "./mapping-field";
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField, TSourceType } from "./types";
interface MappingUIProps {
sourceFields: TSourceField[];
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) {
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);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
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 }]);
}
};
const handleRemoveMapping = (targetFieldId: string) => {
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
};
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 }]);
};
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
const getMappingForTarget = (targetFieldId: string) => {
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
};
const getMappedSourceField = (targetFieldId: string) => {
const mapping = getMappingForTarget(targetFieldId);
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
};
const isSourceFieldMapped = (sourceFieldId: string) =>
mappings.some((m) => m.sourceFieldId === sourceFieldId);
const activeField = activeId ? getSourceFieldById(activeId) : null;
const getSourceTypeLabel = () => {
switch (sourceType) {
case "webhook":
return "Webhook Payload";
case "email":
return "Email Fields";
case "csv":
return "CSV Columns";
default:
return "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">
<h4 className="text-sm font-medium text-slate-700">{getSourceTypeLabel()}</h4>
{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"}
</p>
</div>
) : (
<div className="space-y-2">
{sourceFields.map((field) => (
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
))}
</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>
{/* Required Fields */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Required</p>
{requiredFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
{/* Optional Fields */}
<div className="mt-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Optional</p>
{optionalFields.map((field) => (
<DroppableTargetField
key={field.id}
field={field}
mappedSourceField={getMappedSourceField(field.id) ?? null}
mapping={getMappingForTarget(field.id)}
onRemoveMapping={() => handleRemoveMapping(field.id)}
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
/>
))}
</div>
</div>
</div>
<DragOverlay>
{activeField ? (
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
<span className="font-medium">{activeField.name}</span>
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { Badge } from "@/modules/ui/components/badge";
import { SOURCE_OPTIONS, TSourceType } from "./types";
interface SourceTypeSelectorProps {
selectedType: TSourceType | null;
onSelectType: (type: TSourceType) => void;
}
export function SourceTypeSelector({ selectedType, onSelectType }: SourceTypeSelectorProps) {
return (
<div className="space-y-3">
<p className="text-sm text-slate-600">Select the type of feedback source you want to connect:</p>
<div className="space-y-2">
{SOURCE_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-2 w-2 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { CreateSourceModal } from "./create-source-modal";
import { EditSourceModal } from "./edit-source-modal";
import { SourcesTable } from "./sources-table";
import { TSourceConnection } from "./types";
interface SourcesSectionProps {
environmentId: string;
}
export function SourcesSection({ environmentId }: SourcesSectionProps) {
const [sources, setSources] = useState<TSourceConnection[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingSource, setEditingSource] = useState<TSourceConnection | null>(null);
const handleCreateSource = (source: TSourceConnection) => {
setSources((prev) => [...prev, source]);
};
const handleUpdateSource = (updatedSource: TSourceConnection) => {
setSources((prev) => prev.map((s) => (s.id === updatedSource.id ? updatedSource : s)));
};
const handleDeleteSource = (sourceId: string) => {
setSources((prev) => prev.filter((s) => s.id !== sourceId));
};
const handleSourceClick = (source: TSourceConnection) => {
setEditingSource(source);
};
return (
<PageContentWrapper>
<PageHeader
pageTitle="Unify Feedback"
cta={
<CreateSourceModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateSource={handleCreateSource}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-6">
<SourcesTable sources={sources} onSourceClick={handleSourceClick} />
</div>
<EditSourceModal
source={editingSource}
open={editingSource !== null}
onOpenChange={(open) => !open && setEditingSource(null)}
onUpdateSource={handleUpdateSource}
onDeleteSource={handleDeleteSource}
/>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { FileSpreadsheetIcon, GlobeIcon, MailIcon, MessageSquareIcon, WebhookIcon } from "lucide-react";
import { TSourceType } from "./types";
interface SourcesTableDataRowProps {
id: string;
name: string;
type: TSourceType;
mappingsCount: number;
createdAt: Date;
onClick: () => void;
}
function getSourceIcon(type: TSourceType) {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
case "webhook":
return <WebhookIcon className="h-4 w-4 text-slate-500" />;
case "email":
return <MailIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
case "slack":
return <MessageSquareIcon className="h-4 w-4 text-slate-500" />;
default:
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
}
}
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,
type,
mappingsCount,
createdAt,
onClick,
}: SourcesTableDataRowProps) {
return (
<div
key={id}
role="button"
tabIndex={0}
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClick();
}
}}>
<div className="col-span-1 flex items-center pl-4">
<div className="flex items-center gap-2">
{getSourceIcon(type)}
<span className="hidden text-xs text-slate-500 sm:inline">{getSourceTypeLabel(type)}</span>
</div>
</div>
<div className="col-span-6 flex items-center">
<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"}
</div>
<div className="col-span-3 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
{formatDistanceToNow(createdAt, { addSuffix: true })}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { SourcesTableDataRow } from "./sources-table-data-row";
import { TSourceConnection } from "./types";
interface SourcesTableProps {
sources: TSourceConnection[];
onSourceClick: (source: TSourceConnection) => void;
}
export function SourcesTable({ sources, onSourceClick }: SourcesTableProps) {
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-1 pl-6">Type</div>
<div className="col-span-6">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>
{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>
</div>
) : (
<div className="divide-y divide-slate-100">
{sources.map((source) => (
<SourcesTableDataRow
key={source.id}
id={source.id}
name={source.name}
type={source.type}
mappingsCount={source.mappings.length}
createdAt={source.createdAt}
onClick={() => onSourceClick(source)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,543 @@
// Source types for the feedback source connections
export type TSourceType = "formbricks" | "webhook" | "email" | "csv" | "slack";
export interface TSourceOption {
id: TSourceType;
name: string;
description: string;
disabled: boolean;
badge?: {
text: string;
type: "success" | "gray" | "warning";
};
}
export const SOURCE_OPTIONS: TSourceOption[] = [
{
id: "formbricks",
name: "Formbricks Surveys",
description: "Connect feedback from your Formbricks surveys",
disabled: false,
},
{
id: "webhook",
name: "Webhook",
description: "Receive feedback via webhook with custom mapping",
disabled: false,
},
{
id: "email",
name: "Email",
description: "Import feedback from email with custom mapping",
disabled: false,
},
{
id: "csv",
name: "CSV Import",
description: "Import feedback from CSV files",
disabled: false,
},
{
id: "slack",
name: "Slack Message",
description: "Connect feedback from Slack channels",
disabled: true,
badge: {
text: "Coming soon",
type: "gray",
},
},
];
// Formbricks Survey types for survey selection
export interface TFormbricksSurveyQuestion {
id: string;
type: "openText" | "rating" | "nps" | "csat" | "multipleChoice" | "checkbox" | "date";
headline: string;
required: boolean;
}
export interface TFormbricksSurvey {
id: string;
name: string;
status: "draft" | "active" | "paused" | "completed";
responseCount: number;
questions: TFormbricksSurveyQuestion[];
createdAt: Date;
}
// Mock surveys for POC
export const MOCK_FORMBRICKS_SURVEYS: TFormbricksSurvey[] = [
{
id: "survey_nps_q1",
name: "Q1 2024 NPS Survey",
status: "active",
responseCount: 1247,
createdAt: new Date("2024-01-15"),
questions: [
{ id: "q_nps", type: "nps", headline: "How likely are you to recommend us?", required: true },
{
id: "q_reason",
type: "openText",
headline: "What's the main reason for your score?",
required: false,
},
{
id: "q_improve",
type: "openText",
headline: "What could we do to improve?",
required: false,
},
],
},
{
id: "survey_product_feedback",
name: "Product Feedback Survey",
status: "active",
responseCount: 523,
createdAt: new Date("2024-02-01"),
questions: [
{
id: "q_satisfaction",
type: "rating",
headline: "How satisfied are you with the product?",
required: true,
},
{
id: "q_features",
type: "multipleChoice",
headline: "Which features do you use most?",
required: true,
},
{
id: "q_missing",
type: "openText",
headline: "What features are you missing?",
required: false,
},
{ id: "q_feedback", type: "openText", headline: "Any other feedback?", required: false },
],
},
{
id: "survey_onboarding",
name: "Onboarding Experience",
status: "active",
responseCount: 89,
createdAt: new Date("2024-03-10"),
questions: [
{ id: "q_easy", type: "csat", headline: "How easy was the onboarding process?", required: true },
{
id: "q_time",
type: "multipleChoice",
headline: "How long did onboarding take?",
required: true,
},
{
id: "q_help",
type: "checkbox",
headline: "Which resources did you find helpful?",
required: false,
},
{
id: "q_suggestions",
type: "openText",
headline: "Any suggestions for improvement?",
required: false,
},
],
},
{
id: "survey_support",
name: "Support Satisfaction",
status: "paused",
responseCount: 312,
createdAt: new Date("2024-01-20"),
questions: [
{
id: "q_support_rating",
type: "rating",
headline: "How would you rate your support experience?",
required: true,
},
{
id: "q_resolved",
type: "multipleChoice",
headline: "Was your issue resolved?",
required: true,
},
{ id: "q_comments", type: "openText", headline: "Additional comments", required: false },
],
},
];
// Helper to get question type label
export function getQuestionTypeLabel(type: TFormbricksSurveyQuestion["type"]): string {
switch (type) {
case "openText":
return "Open Text";
case "rating":
return "Rating";
case "nps":
return "NPS";
case "csat":
return "CSAT";
case "multipleChoice":
return "Multiple Choice";
case "checkbox":
return "Checkbox";
case "date":
return "Date";
default:
return type;
}
}
// Helper to map question type to FeedbackRecord field_type
export function questionTypeToFieldType(type: TFormbricksSurveyQuestion["type"]): TFeedbackRecordFieldType {
switch (type) {
case "openText":
return "text";
case "rating":
return "rating";
case "nps":
return "nps";
case "csat":
return "csat";
case "multipleChoice":
case "checkbox":
return "categorical";
case "date":
return "date";
default:
return "text";
}
}
// Field mapping types - supports both source field mapping and static values
export interface TFieldMapping {
targetFieldId: string;
// Either map from a source field OR set a static value
sourceFieldId?: string;
staticValue?: string;
}
export interface TSourceConnection {
id: string;
name: string;
type: TSourceType;
mappings: TFieldMapping[];
createdAt: Date;
updatedAt: Date;
}
// FeedbackRecord field types (enum values for field_type)
export type TFeedbackRecordFieldType =
| "text"
| "categorical"
| "nps"
| "csat"
| "ces"
| "rating"
| "number"
| "boolean"
| "date";
// Field types for the Hub schema
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
export interface TTargetField {
id: string;
name: string;
type: TTargetFieldType;
required: boolean;
description: string;
// For enum fields, the possible values
enumValues?: string[];
// For string fields, example static values that could be set
exampleStaticValues?: string[];
}
export interface TSourceField {
id: string;
name: string;
type: string;
sampleValue?: string;
}
// Enum values for field_type
export const FIELD_TYPE_ENUM_VALUES: TFeedbackRecordFieldType[] = [
"text",
"categorical",
"nps",
"csat",
"ces",
"rating",
"number",
"boolean",
"date",
];
// Target fields based on the FeedbackRecord schema
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
// Required fields
{
id: "collected_at",
name: "Collected At",
type: "timestamp",
required: true,
description: "When the feedback was originally collected",
exampleStaticValues: ["$now"],
},
{
id: "source_type",
name: "Source Type",
type: "string",
required: true,
description: "Type of source (e.g., survey, review, support)",
exampleStaticValues: ["survey", "review", "support", "email", "qualtrics", "typeform", "intercom"],
},
{
id: "field_id",
name: "Field ID",
type: "string",
required: true,
description: "Unique question/field identifier",
},
{
id: "field_type",
name: "Field Type",
type: "enum",
required: true,
description: "Data type (text, nps, csat, rating, etc.)",
enumValues: FIELD_TYPE_ENUM_VALUES,
},
// Optional fields
{
id: "tenant_id",
name: "Tenant ID",
type: "string",
required: false,
description: "Tenant/organization identifier for multi-tenant deployments",
},
{
id: "response_id",
name: "Response ID",
type: "string",
required: false,
description: "Groups multiple answers from a single submission",
},
{
id: "source_id",
name: "Source ID",
type: "string",
required: false,
description: "Reference to survey/form/ticket/review ID",
},
{
id: "source_name",
name: "Source Name",
type: "string",
required: false,
description: "Human-readable source name for display",
exampleStaticValues: ["Product Feedback", "Customer Support", "NPS Survey", "Qualtrics Import"],
},
{
id: "field_label",
name: "Field Label",
type: "string",
required: false,
description: "Question text or field label for display",
},
{
id: "value_text",
name: "Value (Text)",
type: "string",
required: false,
description: "Text responses (feedback, comments, open-ended answers)",
},
{
id: "value_number",
name: "Value (Number)",
type: "float64",
required: false,
description: "Numeric responses (ratings, scores, NPS, CSAT)",
},
{
id: "value_boolean",
name: "Value (Boolean)",
type: "boolean",
required: false,
description: "Yes/no responses",
},
{
id: "value_date",
name: "Value (Date)",
type: "timestamp",
required: false,
description: "Date/datetime responses",
},
{
id: "metadata",
name: "Metadata",
type: "jsonb",
required: false,
description: "Flexible context (device, location, campaign, custom fields)",
},
{
id: "language",
name: "Language",
type: "string",
required: false,
description: "ISO 639-1 language code (e.g., en, de, fr)",
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
},
{
id: "user_identifier",
name: "User Identifier",
type: "string",
required: false,
description: "Anonymous user ID for tracking (hashed, never PII)",
},
];
// Sample webhook payload for testing
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",
},
};
// Email source fields (simplified)
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...",
},
];
// CSV sample columns
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
// Helper function to parse payload to source fields
export function parsePayloadToFields(payload: Record<string, unknown>): TSourceField[] {
const fields: TSourceField[] = [];
function extractFields(obj: Record<string, unknown>, prefix = ""): void {
for (const [key, value] of Object.entries(obj)) {
const fieldId = prefix ? `${prefix}.${key}` : key;
const fieldName = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
extractFields(value as Record<string, unknown>, fieldId);
} else {
let type = "string";
if (typeof value === "number") type = "number";
if (typeof value === "boolean") type = "boolean";
if (Array.isArray(value)) type = "array";
fields.push({
id: fieldId,
name: fieldName,
type,
sampleValue: String(value),
});
}
}
}
extractFields(payload);
return fields;
}
// Helper function to parse CSV columns to source fields
export function parseCSVColumnsToFields(columns: string): TSourceField[] {
return columns.split(",").map((col) => {
const trimmedCol = col.trim();
return {
id: trimmedCol,
name: trimmedCol,
type: "string",
sampleValue: `Sample ${trimmedCol}`,
};
});
}
// AI suggested mappings for different source types
// Maps source field IDs to target field IDs
export interface TAISuggestedMapping {
// Maps source field ID -> target field ID
fieldMappings: Record<string, string>;
// Static values to set on target fields
staticValues: Record<string, string>;
}
export const AI_SUGGESTED_MAPPINGS: Record<TSourceType, TAISuggestedMapping> = {
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",
customer_id: "user_identifier",
rating: "value_number",
feedback_text: "value_text",
category: "field_label",
},
staticValues: {
source_type: "survey",
field_type: "rating",
},
},
formbricks: {
fieldMappings: {},
staticValues: {
source_type: "survey",
},
},
slack: {
fieldMappings: {},
staticValues: {
source_type: "support",
field_type: "text",
},
},
};
// Modal step types
export type TCreateSourceStep = "selectType" | "mapping";

View File

@@ -0,0 +1,10 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { SourcesSection } from "./components/sources-page-client";
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <SourcesSection environmentId={params.environmentId} />;
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import toast from "react-hot-toast";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
export type AddKeywordModalLevel = "L1" | "L2" | "L3";
interface AddKeywordModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
level: AddKeywordModalLevel;
parentName?: string;
onConfirm: (name: string) => void;
}
export function AddKeywordModal({
open,
onOpenChange,
level,
parentName,
onConfirm,
}: AddKeywordModalProps) {
const [name, setName] = useState("");
const handleClose = (nextOpen: boolean) => {
if (!nextOpen) setName("");
onOpenChange(nextOpen);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) {
toast.error("Please enter a keyword name.");
return;
}
onConfirm(trimmed);
setName("");
onOpenChange(false);
toast.success("Keyword added (demo).");
};
const title =
level === "L1" ? "Add L1 keyword" : level === "L2" ? "Add L2 keyword" : "Add L3 keyword";
const description =
level === "L1"
? "Add a new top-level keyword."
: parentName
? `Add a new ${level} keyword under "${parentName}".`
: `Add a new ${level} keyword.`;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<DialogBody>
<div className="space-y-2">
<Label htmlFor="keyword-name">Keyword name</Label>
<Input
id="keyword-name"
placeholder="e.g. New category"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full"
/>
</div>
</DialogBody>
<DialogFooter className="m-2">
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button type="submit">Add keyword</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import {
ChevronRightIcon,
LightbulbIcon,
MessageCircleIcon,
TriangleAlertIcon,
WrenchIcon,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { Input } from "@/modules/ui/components/input";
import { H4, Small } from "@/modules/ui/components/typography";
import type { TaxonomyDetail, TaxonomyThemeItem } from "../types";
const THEME_COLORS: Record<string, string> = {
red: "bg-red-400",
orange: "bg-orange-400",
yellow: "bg-amber-400",
green: "bg-emerald-500",
slate: "bg-slate-400",
};;
function getThemeIcon(icon?: TaxonomyThemeItem["icon"]) {
switch (icon) {
case "warning":
return <TriangleAlertIcon className="size-4 text-amber-500" />;
case "wrench":
return <WrenchIcon className="size-4 text-slate-500" />;
case "message-circle":
return <MessageCircleIcon className="size-4 text-slate-500" />;
case "lightbulb":
return <LightbulbIcon className="size-4 text-amber-500" />;
default:
return <MessageCircleIcon className="size-4 text-slate-400" />;
}
}
interface ThemeItemRowProps {
item: TaxonomyThemeItem;
depth?: number;
themeSearch: string;
}
function ThemeItemRow({ item, depth = 0, themeSearch }: ThemeItemRowProps) {
const [expanded, setExpanded] = useState(depth === 0 && (item.children?.length ?? 0) > 0);
const hasChildren = item.children && item.children.length > 0;
const labelLower = item.label.toLowerCase();
const matchesSearch =
!themeSearch.trim() || labelLower.includes(themeSearch.trim().toLowerCase());
const childMatches =
hasChildren &&
item.children!.some((c) =>
c.label.toLowerCase().includes(themeSearch.trim().toLowerCase())
);
const show = matchesSearch || childMatches;
if (!show) return null;
return (
<div className="flex flex-col">
<div
className={cn(
"flex items-center gap-2 py-1.5 text-sm",
depth === 0 ? "font-medium text-slate-800" : "text-slate-600"
)}
style={{ paddingLeft: depth * 16 + 4 }}>
<button
type="button"
onClick={() => hasChildren && setExpanded(!expanded)}
className="flex shrink-0 items-center justify-center text-slate-400 hover:text-slate-600">
{hasChildren ? (
<ChevronRightIcon
className={cn("size-4 transition-transform", expanded && "rotate-90")}
/>
) : (
<span className="w-4" />
)}
</button>
{getThemeIcon(item.icon)}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<Small color="muted" className="shrink-0">
{item.count}
</Small>
</div>
{hasChildren && expanded && (
<div className="border-l border-slate-200 pl-2">
{item.children!.map((child) => (
<ThemeItemRow
key={child.id}
item={child}
depth={depth + 1}
themeSearch={themeSearch}
/>
))}
</div>
)}
</div>
);
}
interface TaxonomyDetailPanelProps {
detail: TaxonomyDetail | null;
}
export function TaxonomyDetailPanel({ detail }: TaxonomyDetailPanelProps) {
const [themeSearch, setThemeSearch] = useState("");
if (!detail) {
return (
<div className="flex flex-1 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<Small color="muted">Select a Level 3 keyword to view details.</Small>
</div>
</div>
);
}
const totalThemes = detail.themes.reduce((s, t) => s + t.count, 0);
return (
<div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-col gap-5 overflow-y-auto p-4">
<div className="border-b border-slate-200 pb-4">
<H4 className="mb-1">{detail.keywordName}</H4>
<div className="flex items-center gap-2">
<Small color="muted">{detail.count} responses</Small>
<button
type="button"
className="text-sm font-medium text-slate-600 underline-offset-2 hover:underline">
View all
</button>
</div>
</div>
<div>
<H4 className="mb-1 text-sm">Description</H4>
<Small color="muted" className="leading-relaxed">
{detail.description}
</Small>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<H4 className="text-sm">{detail.themes.length} themes</H4>
<div className="flex h-2 flex-1 max-w-[120px] overflow-hidden rounded-full bg-slate-100">
{detail.themes.map((t) => (
<div
key={t.id}
className={cn(THEME_COLORS[t.color] ?? "bg-slate-400")}
style={{
width: totalThemes ? `${(t.count / totalThemes) * 100}%` : "0%",
}}
title={t.label}
/>
))}
</div>
</div>
<Input
placeholder="Search themes"
value={themeSearch}
onChange={(e) => setThemeSearch(e.target.value)}
className="mb-3 h-9 text-sm"
/>
<div className="space-y-0.5">
{detail.themeItems.map((item) => (
<ThemeItemRow key={item.id} item={item} themeSearch={themeSearch} />
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { cn } from "@/lib/cn";
import { formatCount } from "../lib/mock-data";
import type { TaxonomyKeyword } from "../types";
import { Button } from "@/modules/ui/components/button";
interface TaxonomyKeywordColumnProps {
title: string;
keywords: TaxonomyKeyword[];
selectedId: string | null;
onSelect: (id: string) => void;
addButtonLabel?: string;
onAdd?: () => void;
}
export function TaxonomyKeywordColumn({
title,
keywords,
selectedId,
onSelect,
addButtonLabel,
onAdd,
}: TaxonomyKeywordColumnProps) {
return (
<div className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-4 py-3">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
</div>
<div className="flex min-h-[320px] flex-1 flex-col overflow-y-auto">
{keywords.map((kw) => (
<button
key={kw.id}
type="button"
onClick={() => onSelect(kw.id)}
className={cn(
"grid w-full grid-cols-[1fr,auto] content-center gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors last:border-b-0",
selectedId === kw.id ? "bg-slate-50" : "hover:bg-slate-50"
)}>
<span className="min-w-0 truncate text-sm font-medium text-slate-800">{kw.name}</span>
<span className="text-sm text-slate-500">{formatCount(kw.count)}</span>
</button>
))}
{addButtonLabel && (
<div className="border-t border-slate-200 p-2">
<Button type="button" variant="outline" size="sm" className="w-full" onClick={onAdd}>
+ {addButtonLabel}
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useMemo, useState } from "react";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { Input } from "@/modules/ui/components/input";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
import { getDetailForL3, getL2Keywords, getL3Keywords, MOCK_LEVEL1_KEYWORDS } from "../lib/mock-data";
import type { TaxonomyKeyword } from "../types";
import { AddKeywordModal } from "./AddKeywordModal";
import { TaxonomyDetailPanel } from "./TaxonomyDetailPanel";
import { TaxonomyKeywordColumn } from "./TaxonomyKeywordColumn";
interface TaxonomySectionProps {
environmentId: string;
}
export function TaxonomySection({ environmentId }: TaxonomySectionProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedL1Id, setSelectedL1Id] = useState<string | null>("l1-1");
const [selectedL2Id, setSelectedL2Id] = useState<string | null>("l2-1a");
const [selectedL3Id, setSelectedL3Id] = useState<string | null>("l3-1a");
const [addKeywordModalOpen, setAddKeywordModalOpen] = useState(false);
const [addKeywordModalLevel, setAddKeywordModalLevel] = useState<"L1" | "L2" | "L3">("L1");
const [customL1Keywords, setCustomL1Keywords] = useState<TaxonomyKeyword[]>([]);
const [customL2Keywords, setCustomL2Keywords] = useState<TaxonomyKeyword[]>([]);
const [customL3Keywords, setCustomL3Keywords] = useState<TaxonomyKeyword[]>([]);
const l2Keywords = useMemo(() => {
const fromMock = selectedL1Id ? getL2Keywords(selectedL1Id) : [];
const custom = customL2Keywords.filter((k) => k.parentId === selectedL1Id);
return [...fromMock, ...custom];
}, [selectedL1Id, customL2Keywords]);
const l3Keywords = useMemo(() => {
const fromMock = selectedL2Id ? getL3Keywords(selectedL2Id) : [];
const custom = customL3Keywords.filter((k) => k.parentId === selectedL2Id);
return [...fromMock, ...custom];
}, [selectedL2Id, customL3Keywords]);
const detail = useMemo(
() => (selectedL3Id ? getDetailForL3(selectedL3Id) : null),
[selectedL3Id]
);
const l1Keywords = useMemo(
() => [...MOCK_LEVEL1_KEYWORDS, ...customL1Keywords],
[customL1Keywords]
);
const filterKeywords = (list: TaxonomyKeyword[], q: string) => {
if (!q.trim()) return list;
const lower = q.trim().toLowerCase();
return list.filter((k) => k.name.toLowerCase().includes(lower));
};
const filteredL1 = useMemo(
() => filterKeywords(l1Keywords, searchQuery),
[l1Keywords, searchQuery]
);
const filteredL2 = useMemo(() => filterKeywords(l2Keywords, searchQuery), [l2Keywords, searchQuery]);
const filteredL3 = useMemo(() => filterKeywords(l3Keywords, searchQuery), [l3Keywords, searchQuery]);
const selectedL1Name = useMemo(
() => l1Keywords.find((k) => k.id === selectedL1Id)?.name,
[l1Keywords, selectedL1Id]
);
const selectedL2Name = useMemo(
() => l2Keywords.find((k) => k.id === selectedL2Id)?.name,
[l2Keywords, selectedL2Id]
);
const addKeywordParentName =
addKeywordModalLevel === "L2" ? selectedL1Name : addKeywordModalLevel === "L3" ? selectedL2Name : undefined;
const handleAddKeyword = (name: string) => {
if (addKeywordModalLevel === "L1") {
const id = `custom-l1-${crypto.randomUUID()}`;
setCustomL1Keywords((prev) => [...prev, { id, name, count: 0 }]);
setSelectedL1Id(id);
setSelectedL2Id(null);
setSelectedL3Id(null);
} else if (addKeywordModalLevel === "L2" && selectedL1Id) {
const id = `custom-l2-${crypto.randomUUID()}`;
setCustomL2Keywords((prev) => [
...prev,
{ id, name, count: 0, parentId: selectedL1Id },
]);
setSelectedL2Id(id);
setSelectedL3Id(null);
} else if (addKeywordModalLevel === "L3" && selectedL2Id) {
const id = `custom-l3-${crypto.randomUUID()}`;
setCustomL3Keywords((prev) => [
...prev,
{ id, name, count: 0, parentId: selectedL2Id },
]);
setSelectedL3Id(id);
}
};
return (
<PageContentWrapper>
<PageHeader pageTitle="Unify Feedback">
<UnifyConfigNavigation environmentId={environmentId} />
</PageHeader>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Input
placeholder="Find in taxonomy..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-md"
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
<div className="lg:col-span-3">
<TaxonomyKeywordColumn
title={`Level 1 Keywords (${filteredL1.length})`}
keywords={filteredL1}
selectedId={selectedL1Id}
onSelect={(id) => {
setSelectedL1Id(id);
setSelectedL2Id(null);
setSelectedL3Id(null);
}}
addButtonLabel="Add L1 Keyword"
onAdd={() => {
setAddKeywordModalLevel("L1");
setAddKeywordModalOpen(true);
}}
/>
</div>
<div className="lg:col-span-3">
<TaxonomyKeywordColumn
title={`Level 2 Keywords (${filteredL2.length})`}
keywords={filteredL2}
selectedId={selectedL2Id}
onSelect={(id) => {
setSelectedL2Id(id);
setSelectedL3Id(null);
}}
addButtonLabel="Add L2 Keyword"
onAdd={() => {
setAddKeywordModalLevel("L2");
setAddKeywordModalOpen(true);
}}
/>
</div>
<div className="lg:col-span-3">
<TaxonomyKeywordColumn
title={`Level 3 Keywords (${filteredL3.length})`}
keywords={filteredL3}
selectedId={selectedL3Id}
onSelect={setSelectedL3Id}
addButtonLabel="Add L3 Keyword"
onAdd={() => {
setAddKeywordModalLevel("L3");
setAddKeywordModalOpen(true);
}}
/>
</div>
<div className="min-h-[400px] lg:col-span-3">
<TaxonomyDetailPanel detail={detail} />
</div>
</div>
</div>
<AddKeywordModal
open={addKeywordModalOpen}
onOpenChange={setAddKeywordModalOpen}
level={addKeywordModalLevel}
parentName={addKeywordParentName}
onConfirm={handleAddKeyword}
/>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,175 @@
import type { TaxonomyDetail, TaxonomyKeyword, TaxonomyThemeItem } from "../types";
export const MOCK_LEVEL1_KEYWORDS: TaxonomyKeyword[] = [
{ id: "l1-1", name: "Dashboard", count: 12400 },
{ id: "l1-2", name: "Usability", count: 8200 },
{ id: "l1-3", name: "Performance", count: 5600 },
{ id: "l1-4", name: "Miscellaneous", count: 3100 },
];
export const MOCK_LEVEL2_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
"l1-1": [
{ id: "l2-1a", name: "Survey overview", count: 5200, parentId: "l1-1" },
{ id: "l2-2a", name: "Response metrics", count: 3800, parentId: "l1-1" },
{ id: "l2-3a", name: "Analytics & reports", count: 2400, parentId: "l1-1" },
{ id: "l2-4a", name: "Widgets & embedding", count: 800, parentId: "l1-1" },
{ id: "l2-5a", name: "Not specified", count: 200, parentId: "l1-1" },
],
"l1-2": [
{ id: "l2-1b", name: "Survey builder", count: 3200, parentId: "l1-2" },
{ id: "l2-2b", name: "Question types", count: 2100, parentId: "l1-2" },
{ id: "l2-3b", name: "Logic & branching", count: 1400, parentId: "l1-2" },
{ id: "l2-4b", name: "Styling & theming", count: 900, parentId: "l1-2" },
{ id: "l2-5b", name: "Not specified", count: 600, parentId: "l1-2" },
],
"l1-3": [
{ id: "l2-1c", name: "Load time & speed", count: 2200, parentId: "l1-3" },
{ id: "l2-2c", name: "Survey rendering", count: 1600, parentId: "l1-3" },
{ id: "l2-3c", name: "SDK & integration", count: 1100, parentId: "l1-3" },
{ id: "l2-4c", name: "API & data sync", count: 500, parentId: "l1-3" },
{ id: "l2-5c", name: "Not specified", count: 200, parentId: "l1-3" },
],
"l1-4": [
{ id: "l2-1d", name: "Feature requests", count: 1500, parentId: "l1-4" },
{ id: "l2-2d", name: "Bug reports", count: 900, parentId: "l1-4" },
{ id: "l2-3d", name: "Documentation", count: 400, parentId: "l1-4" },
{ id: "l2-4d", name: "Not specified", count: 300, parentId: "l1-4" },
],
};
export const MOCK_LEVEL3_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
"l2-1a": [
{ id: "l3-1a", name: "In-app surveys", count: 2800, parentId: "l2-1a" },
{ id: "l3-2a", name: "Link surveys", count: 1600, parentId: "l2-1a" },
{ id: "l3-3a", name: "Response summary", count: 600, parentId: "l2-1a" },
{ id: "l3-4a", name: "Not specified", count: 200, parentId: "l2-1a" },
],
"l2-2a": [
{ id: "l3-5a", name: "Completion rate", count: 1800, parentId: "l2-2a" },
{ id: "l3-6a", name: "Drop-off points", count: 1200, parentId: "l2-2a" },
{ id: "l3-7a", name: "Response distribution", count: 800, parentId: "l2-2a" },
],
"l2-1b": [
{ id: "l3-1b", name: "Drag & drop editor", count: 1400, parentId: "l2-1b" },
{ id: "l3-2b", name: "Question configuration", count: 900, parentId: "l2-1b" },
{ id: "l3-3b", name: "Multi-language surveys", count: 500, parentId: "l2-1b" },
{ id: "l3-4b", name: "Not specified", count: 400, parentId: "l2-1b" },
],
"l2-2b": [
{ id: "l3-5b", name: "Open text & NPS", count: 1100, parentId: "l2-2b" },
{ id: "l3-6b", name: "Multiple choice & rating", count: 600, parentId: "l2-2b" },
{ id: "l3-7b", name: "File upload & date picker", count: 400, parentId: "l2-2b" },
],
"l2-1c": [
{ id: "l3-1c", name: "Widget initialization", count: 900, parentId: "l2-1c" },
{ id: "l3-2c", name: "Survey load delay", count: 700, parentId: "l2-1c" },
{ id: "l3-3c", name: "Bundle size impact", count: 600, parentId: "l2-1c" },
],
"l2-1d": [
{ id: "l3-1d", name: "New question types", count: 600, parentId: "l2-1d" },
{ id: "l3-2d", name: "Integrations & webhooks", count: 500, parentId: "l2-1d" },
{ id: "l3-3d", name: "Export & reporting", count: 400, parentId: "l2-1d" },
],
};
export function getL2Keywords(parentL1Id: string): TaxonomyKeyword[] {
return MOCK_LEVEL2_KEYWORDS[parentL1Id] ?? [];
}
export function getL3Keywords(parentL2Id: string): TaxonomyKeyword[] {
return MOCK_LEVEL3_KEYWORDS[parentL2Id] ?? [];
}
export const MOCK_DETAIL_L3: Record<string, TaxonomyDetail> = {
"l3-1a": {
keywordId: "l3-1a",
keywordName: "In-app surveys",
count: 2800,
description:
"Feedback collected directly inside your product. Formbricks in-app surveys are triggered by actions (e.g. page view, click) and can be shown as modal, full-width, or inline widgets.",
themes: [
{ id: "t1", label: "Issues", count: 1200, color: "red" },
{ id: "t2", label: "Ideas", count: 900, color: "orange" },
{ id: "t3", label: "Questions", count: 500, color: "yellow" },
{ id: "t4", label: "Other", count: 200, color: "green" },
],
themeItems: [
{
id: "ti-1",
label: "Survey not showing on trigger",
count: 420,
icon: "warning",
children: [
{ id: "ti-1-1", label: "Wrong environment or survey ID", count: 200 },
{ id: "ti-1-2", label: "Trigger conditions not met", count: 150 },
{ id: "ti-1-3", label: "SDK not loaded in time", count: 70 },
],
},
{ id: "ti-2", label: "Positioning and placement", count: 310, icon: "wrench" },
{ id: "ti-3", label: "Request for more trigger types", count: 280, icon: "lightbulb" },
{ id: "ti-4", label: "Miscellaneous in-app feedback", count: 190, icon: "message-circle" },
],
},
"l3-1b": {
keywordId: "l3-1b",
keywordName: "Drag & drop editor",
count: 1400,
description:
"The Formbricks survey builder lets you add and reorder questions with drag and drop, configure question settings, and preview surveys before publishing.",
themes: [
{ id: "t1", label: "Issues", count: 600, color: "red" },
{ id: "t2", label: "Ideas", count: 500, color: "orange" },
{ id: "t3", label: "Questions", count: 250, color: "yellow" },
{ id: "t4", label: "Other", count: 50, color: "green" },
],
themeItems: [
{ id: "ti-1", label: "Reordering fails with many questions", count: 220, icon: "warning" },
{ id: "ti-2", label: "Request for keyboard shortcuts", count: 180, icon: "lightbulb" },
{ id: "ti-3", label: "Undo / redo in editor", count: 150, icon: "lightbulb" },
{ id: "ti-4", label: "Miscellaneous builder feedback", count: 100, icon: "message-circle" },
],
},
"l3-1c": {
keywordId: "l3-1c",
keywordName: "Widget initialization",
count: 900,
description:
"How quickly the Formbricks widget loads and becomes ready to display surveys. Includes script load time, SDK init, and first-paint for survey UI.",
themes: [
{ id: "t1", label: "Issues", count: 550, color: "red" },
{ id: "t2", label: "Ideas", count: 250, color: "orange" },
{ id: "t3", label: "Questions", count: 100, color: "yellow" },
{ id: "t4", label: "Other", count: 0, color: "green" },
],
themeItems: [
{ id: "ti-1", label: "Slow init on mobile networks", count: 280, icon: "warning" },
{ id: "ti-2", label: "Blocking main thread", count: 180, icon: "warning" },
{ id: "ti-3", label: "Lazy-load SDK suggestion", count: 120, icon: "lightbulb" },
],
},
"l3-1d": {
keywordId: "l3-1d",
keywordName: "New question types",
count: 600,
description:
"Requests for additional question types in Formbricks surveys (e.g. matrix, ranking, sliders, image choice) to capture different kinds of feedback.",
themes: [
{ id: "t1", label: "Ideas", count: 450, color: "orange" },
{ id: "t2", label: "Questions", count: 100, color: "yellow" },
{ id: "t3", label: "Other", count: 50, color: "green" },
],
themeItems: [
{ id: "ti-1", label: "Matrix / grid question", count: 180, icon: "lightbulb" },
{ id: "ti-2", label: "Ranking question type", count: 120, icon: "lightbulb" },
{ id: "ti-3", label: "Slider and scale variants", count: 90, icon: "lightbulb" },
],
},
};
export function getDetailForL3(keywordId: string): TaxonomyDetail | null {
return MOCK_DETAIL_L3[keywordId] ?? null;
}
export function formatCount(n: number): string {
return n.toLocaleString();
}

View File

@@ -0,0 +1,10 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TaxonomySection } from "./components/TaxonomySection";
export default async function UnifyTaxonomyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
await getEnvironmentAuth(params.environmentId);
return <TaxonomySection environmentId={params.environmentId} />;
}

View File

@@ -0,0 +1,30 @@
export interface TaxonomyKeyword {
id: string;
name: string;
count: number;
parentId?: string;
}
export interface TaxonomyTheme {
id: string;
label: string;
count: number;
color: "red" | "orange" | "yellow" | "green" | "slate";
}
export interface TaxonomyThemeItem {
id: string;
label: string;
count: number;
icon?: "warning" | "wrench" | "message-circle" | "lightbulb";
children?: TaxonomyThemeItem[];
}
export interface TaxonomyDetail {
keywordId: string;
keywordName: string;
count: number;
description: string;
themes: TaxonomyTheme[];
themeItems: TaxonomyThemeItem[];
}

View File

@@ -1,6 +1,14 @@
"use client";
import { BlocksIcon, BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
import {
BlocksIcon,
BrushIcon,
Cable,
LanguagesIcon,
ListChecksIcon,
TagIcon,
UsersIcon,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
@@ -69,6 +77,13 @@ export const ProjectConfigNavigation = ({
href: `/environments/${environmentId}/workspace/tags`,
current: pathname?.includes("/tags"),
},
{
id: "unify",
label: "Unify Feedback",
icon: <Cable className="h-5 w-5" />,
href: `/environments/${environmentId}/workspace/unify`,
current: pathname?.includes("/unify"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;