mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-19 11:11:05 -05:00
feat: Add mock data and UI for taxanomy & knowledge
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"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 { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
|
||||
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;
|
||||
}
|
||||
onAdd({
|
||||
id: crypto.randomUUID(),
|
||||
type: "link",
|
||||
title: linkTitle.trim() || undefined,
|
||||
url: linkUrl.trim(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
toast.success("Link added.");
|
||||
};
|
||||
|
||||
const handleAddNote = () => {
|
||||
if (!noteContent.trim()) {
|
||||
toast.error("Please enter some text.");
|
||||
return;
|
||||
}
|
||||
onAdd({
|
||||
id: crypto.randomUUID(),
|
||||
type: "note",
|
||||
content: noteContent.trim(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
toast.success("Note added.");
|
||||
};
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (!uploadedDocUrl) {
|
||||
toast.error("Please upload a document first.");
|
||||
return;
|
||||
}
|
||||
onAdd({
|
||||
id: crypto.randomUUID(),
|
||||
type: "file",
|
||||
title: uploadedFileName ?? undefined,
|
||||
fileUrl: uploadedDocUrl,
|
||||
fileName: uploadedFileName ?? undefined,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import type { KnowledgeItem } from "../types";
|
||||
import { AddKnowledgeModal } from "./AddKnowledgeModal";
|
||||
import { KnowledgeTable } from "./KnowledgeTable";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
|
||||
interface KnowledgeSectionProps {
|
||||
environmentId: string;
|
||||
isStorageConfigured: boolean;
|
||||
}
|
||||
|
||||
export function KnowledgeSection({ environmentId, isStorageConfigured }: KnowledgeSectionProps) {
|
||||
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
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} />
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { FileTextIcon, LinkIcon, StickyNoteIcon } from "lucide-react";
|
||||
import type { KnowledgeItem } from "../types";
|
||||
|
||||
interface KnowledgeTableProps {
|
||||
items: KnowledgeItem[];
|
||||
}
|
||||
|
||||
function getTypeIcon(type: KnowledgeItem["type"]) {
|
||||
switch (type) {
|
||||
case "link":
|
||||
return <LinkIcon className="size-5 text-slate-500" />;
|
||||
case "file":
|
||||
return <FileTextIcon className="size-5 text-slate-500" />;
|
||||
case "note":
|
||||
return <StickyNoteIcon className="size-5 text-slate-500" />;
|
||||
default:
|
||||
return <FileTextIcon className="size-5 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 }: KnowledgeTableProps) {
|
||||
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-8">Title / Content</div>
|
||||
<div className="col-span-3 hidden pr-6 text-right sm:block">Created</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">
|
||||
<div className="col-span-1 flex items-center pl-6">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-600">
|
||||
{getTypeIcon(item.type)}
|
||||
<span className="hidden sm:inline">{getTypeLabel(item.type)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-8 flex flex-col justify-center">
|
||||
<div className="truncate 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 className="col-span-3 flex items-center justify-end pr-6 text-right text-sm text-slate-500">
|
||||
{formatDistanceToNow(item.createdAt, { addSuffix: true }).replace("about ", "")}
|
||||
<span className="ml-1 hidden sm:inline">
|
||||
({format(item.createdAt, "MMM d, yyyy")})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
|
||||
import { KnowledgeSection } from "./components/KnowledgeSection";
|
||||
|
||||
export default async function UnifyKnowledgePage(props: { params: Promise<{ environmentId: string }> }) {
|
||||
const params = await props.params;
|
||||
@@ -9,16 +8,9 @@ export default async function UnifyKnowledgePage(props: { params: Promise<{ envi
|
||||
await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Unify Feedback">
|
||||
<UnifyConfigNavigation environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Knowledge</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Unify and manage feedback from all your channels in one place.
|
||||
</p>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
<KnowledgeSection
|
||||
environmentId={params.environmentId}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type KnowledgeItemType = "link" | "note" | "file";
|
||||
|
||||
export interface KnowledgeItem {
|
||||
id: string;
|
||||
type: KnowledgeItemType;
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,24 +1,10 @@
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
|
||||
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 (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Unify Feedback">
|
||||
<UnifyConfigNavigation environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Taxonomy</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Unify and manage feedback from all your channels in one place.
|
||||
</p>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
return <TaxonomySection environmentId={params.environmentId} />;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user