mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 19:38:53 -05:00
Compare commits
11 Commits
4.6.0-rc.2
...
feat/unify
| Author | SHA1 | Date | |
|---|---|---|---|
| 590c85d1ca | |||
| 39c99baaac | |||
| 238b2adf3f | |||
| 8f7d225d6a | |||
| 094b6dedba | |||
| 36f0be07c4 | |||
| e079055a43 | |||
| 9ae9a3a9fc | |||
| b4606c0113 | |||
| 6be654ab60 | |||
| 95c2e24416 |
@@ -23,7 +23,7 @@
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"esbuild": "0.27.2",
|
||||
"esbuild": "0.25.12",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"prop-types": "15.8.1",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+45
@@ -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>
|
||||
);
|
||||
}
|
||||
+107
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+79
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AnalyzeSection } from "./AnalyzeSection";
|
||||
export { CreateDashboardModal } from "./create-dashboard-modal";
|
||||
export { DashboardsTable } from "./dashboards-table";
|
||||
export type { TDashboard } from "./types";
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface TDashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
widgetCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
+39
@@ -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} />;
|
||||
};
|
||||
+90
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ControlsSection } from "./ControlsSection";
|
||||
@@ -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} />;
|
||||
}
|
||||
+256
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+47
@@ -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>
|
||||
);
|
||||
}
|
||||
+139
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
+396
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+327
@@ -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>
|
||||
);
|
||||
}
|
||||
+299
@@ -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>
|
||||
);
|
||||
}
|
||||
+201
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
@@ -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";
|
||||
+305
@@ -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>
|
||||
);
|
||||
}
|
||||
+232
@@ -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>
|
||||
);
|
||||
}
|
||||
+51
@@ -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>
|
||||
);
|
||||
}
|
||||
+64
@@ -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>
|
||||
);
|
||||
}
|
||||
+87
@@ -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>
|
||||
);
|
||||
}
|
||||
+41
@@ -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>
|
||||
);
|
||||
}
|
||||
+543
@@ -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";
|
||||
@@ -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} />;
|
||||
}
|
||||
+94
@@ -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>
|
||||
);
|
||||
}
|
||||
+174
@@ -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>
|
||||
);
|
||||
}
|
||||
+54
@@ -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>
|
||||
);
|
||||
}
|
||||
+178
@@ -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>
|
||||
);
|
||||
}
|
||||
+175
@@ -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();
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
+11
@@ -3,6 +3,7 @@
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -67,6 +68,16 @@ export const SingleResponseCardBody = ({
|
||||
<VerifiedEmail responseData={response.data} />
|
||||
)}
|
||||
{elements.map((question) => {
|
||||
// Skip CTA elements without external buttons only if they have no response data
|
||||
// This preserves historical data from when buttonExternal was true
|
||||
if (
|
||||
question.type === TSurveyElementTypeEnum.CTA &&
|
||||
!question.buttonExternal &&
|
||||
!response.data[question.id]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skipped = skippedQuestions.find((skippedQuestionElement) =>
|
||||
skippedQuestionElement.includes(question.id)
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -425,11 +425,19 @@ export const SurveyMenuBar = ({
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
|
||||
await updateSurveyAction({
|
||||
const publishResult = await updateSurveyAction({
|
||||
...localSurvey,
|
||||
status,
|
||||
segment,
|
||||
});
|
||||
|
||||
if (!publishResult?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(publishResult);
|
||||
toast.error(errorMessage);
|
||||
setIsSurveyPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSurveyPublishing(false);
|
||||
// Set flag to prevent beforeunload warning during navigation
|
||||
isSuccessfullySavedRef.current = true;
|
||||
@@ -467,7 +475,7 @@ export const SurveyMenuBar = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
|
||||
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
|
||||
{!isStorageConfigured && (
|
||||
<div>
|
||||
|
||||
@@ -259,6 +259,7 @@ export const PreviewSurvey = ({
|
||||
setBlockId = f;
|
||||
}}
|
||||
onFinished={onFinished}
|
||||
placement={placement}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
/>
|
||||
</Modal>
|
||||
@@ -363,6 +364,7 @@ export const PreviewSurvey = ({
|
||||
}}
|
||||
onFinished={onFinished}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
placement={placement}
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"jiti": "2.4.2",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"lexical": "0.36.2",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"lucide-react": "0.507.0",
|
||||
"markdown-it": "14.1.0",
|
||||
"mime-types": "3.0.1",
|
||||
@@ -158,7 +158,7 @@
|
||||
"autoprefixer": "10.4.21",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "16.5.0",
|
||||
"esbuild": "0.25.11",
|
||||
"esbuild": "0.25.12",
|
||||
"postcss": "8.5.3",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -47,8 +47,13 @@ run_with_timeout() {
|
||||
}
|
||||
|
||||
|
||||
echo "🗃️ Running database migrations..."
|
||||
run_with_timeout 300 "database migration" node packages/database/dist/scripts/apply-migrations.js
|
||||
# Check if migrations should be skipped (e.g., when using Helm migration job)
|
||||
if [ "${SKIP_STARTUP_MIGRATION:-false}" = "true" ]; then
|
||||
echo "⏭️ Skipping startup migrations (handled by migration job)"
|
||||
else
|
||||
echo "🗃️ Running database migrations..."
|
||||
run_with_timeout 300 "database migration" node packages/database/dist/scripts/apply-migrations.js
|
||||
fi
|
||||
|
||||
echo "🗃️ Running SAML database setup..."
|
||||
run_with_timeout 60 "SAML database setup" node packages/database/dist/scripts/create-saml-database.js
|
||||
|
||||
@@ -127,6 +127,10 @@ spec:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if .Values.migration.enabled }}
|
||||
- name: SKIP_STARTUP_MIGRATION
|
||||
value: "true"
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.deployment.env }}
|
||||
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
|
||||
{{- if kindIs "string" $value }}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{{- if (.Values.externalSecret).enabled }}
|
||||
{{- range $nameSuffix, $data := .Values.externalSecret.files }}
|
||||
---
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: {{ template "formbricks.name" $ }}-{{ $nameSuffix }}
|
||||
@@ -0,0 +1,95 @@
|
||||
{{- if .Values.migration.enabled }}
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "formbricks.name" . }}-migration
|
||||
labels:
|
||||
{{- include "formbricks.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
# ArgoCD sync hooks
|
||||
argocd.argoproj.io/hook: PreSync
|
||||
argocd.argoproj.io/hook-delete-policy: HookSucceeded
|
||||
argocd.argoproj.io/sync-wave: "-1"
|
||||
{{- if .Values.migration.annotations }}
|
||||
{{- toYaml .Values.migration.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ttlSecondsAfterFinished: {{ .Values.migration.ttlSecondsAfterFinished | default 300 }}
|
||||
backoffLimit: {{ .Values.migration.backoffLimit | default 3 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "formbricks.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: migration
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if .Values.deployment.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml .Values.deployment.nodeSelector | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.deployment.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml .Values.deployment.tolerations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.deployment.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.rbac.serviceAccount.enabled }}
|
||||
serviceAccountName: {{ .Values.rbac.serviceAccount.name | default (include "formbricks.name" .) }}
|
||||
{{- end }}
|
||||
{{- if .Values.deployment.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.deployment.securityContext | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migration
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
- packages/database/dist/scripts/apply-migrations.js
|
||||
{{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
|
||||
envFrom:
|
||||
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
|
||||
- secretRef:
|
||||
name: {{ template "formbricks.name" . }}-app-secrets
|
||||
{{- end }}
|
||||
{{- range $value := .Values.deployment.envFrom }}
|
||||
{{- if (eq .type "configmap") }}
|
||||
- configMapRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if (eq .type "secret") }}
|
||||
- secretRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- range $key, $value := .Values.deployment.env }}
|
||||
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
|
||||
{{- else }}
|
||||
{{- toYaml $value | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.migration.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.migration.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -28,6 +28,32 @@ enterprise:
|
||||
enabled: false
|
||||
licenseKey: ""
|
||||
|
||||
##########################################################
|
||||
# Database Migration Job Configuration Helm
|
||||
##########################################################
|
||||
migration:
|
||||
# Enable migration job for ArgoCD deployments
|
||||
# When enabled, migrations run as a PreSync hook before the deployment
|
||||
# and the startup migration in the container is skipped
|
||||
enabled: true
|
||||
|
||||
# Additional annotations for the migration job
|
||||
annotations: {}
|
||||
|
||||
# Time to keep the job after completion (seconds)
|
||||
ttlSecondsAfterFinished: 300
|
||||
|
||||
# Number of retries before marking the job as failed
|
||||
backoffLimit: 3
|
||||
|
||||
# Resource requests and limits for the migration job
|
||||
resources:
|
||||
limits:
|
||||
memory: 512Mi
|
||||
requests:
|
||||
memory: 256Mi
|
||||
cpu: "100m"
|
||||
|
||||
##########################################################
|
||||
# Deployment Configuration
|
||||
##########################################################
|
||||
@@ -170,7 +170,7 @@ export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: stri
|
||||
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code === language.toLowerCase() ||
|
||||
surveyLanguage.language.code.toLowerCase() === language.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, getRTLScaleOptionClasses } from "@/lib/utils";
|
||||
|
||||
interface NPSProps {
|
||||
/** Unique identifier for the element container */
|
||||
@@ -97,18 +97,9 @@ function NPS({
|
||||
const isLast = number === 10; // Last option is 10
|
||||
const isFirst = number === 0; // First option is 0
|
||||
|
||||
// Determine border radius and border classes
|
||||
// Use right border for all items to create separators, left border only on first item
|
||||
let borderRadiusClasses = "";
|
||||
let borderClasses = "border-t border-b border-r";
|
||||
|
||||
if (isFirst) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
|
||||
borderClasses = "border-t border-b border-l border-r";
|
||||
} else if (isLast) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
|
||||
// Last item keeps right border for rounded corner
|
||||
}
|
||||
// Use CSS logical properties for RTL-aware borders and border radius
|
||||
// The fieldset's dir attribute automatically handles direction
|
||||
const { borderRadiusClasses, borderClasses } = getRTLScaleOptionClasses(isFirst, isLast);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
@@ -183,7 +174,7 @@ function NPS({
|
||||
{/* NPS Options */}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full px-[2px]">
|
||||
<fieldset className="w-full px-[2px]" dir={dir}>
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
<div className="flex w-full">{npsOptions.map((number) => renderNPSOption(number))}</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
TiredFace,
|
||||
WearyFace,
|
||||
} from "@/components/general/smileys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, getRTLScaleOptionClasses } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Get smiley color class based on range and index
|
||||
@@ -220,18 +220,9 @@ function Rating({
|
||||
const isLast = totalLength === number;
|
||||
const isFirst = number === 1;
|
||||
|
||||
// Determine border radius and border classes
|
||||
// Use right border for all items to create separators, left border only on first item
|
||||
let borderRadiusClasses = "";
|
||||
let borderClasses = "border-t border-b border-r";
|
||||
|
||||
if (isFirst) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
|
||||
borderClasses = "border-t border-b border-l border-r";
|
||||
} else if (isLast) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
|
||||
// Last item keeps right border for rounded corner
|
||||
}
|
||||
// Use CSS logical properties for RTL-aware borders and border radius
|
||||
// The parent div's dir attribute automatically handles direction
|
||||
const { borderRadiusClasses, borderClasses } = getRTLScaleOptionClasses(isFirst, isLast);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
@@ -418,7 +409,7 @@ function Rating({
|
||||
{/* Rating Options */}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full">
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
<div className="flex w-full px-[2px]">
|
||||
{ratingOptions.map((number, index) => {
|
||||
|
||||
@@ -35,3 +35,29 @@ export const stripInlineStyles = (html: string): string => {
|
||||
KEEP_CONTENT: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate RTL-aware border radius and border classes for rating/NPS scale options
|
||||
* Uses CSS logical properties that automatically adapt to text direction
|
||||
* @param isFirst - Whether this is the first item in the scale
|
||||
* @param isLast - Whether this is the last item in the scale
|
||||
* @returns Object containing borderRadiusClasses and borderClasses
|
||||
*/
|
||||
export const getRTLScaleOptionClasses = (
|
||||
isFirst: boolean,
|
||||
isLast: boolean
|
||||
): { borderRadiusClasses: string; borderClasses: string } => {
|
||||
const borderRadiusClasses = cn(
|
||||
isFirst &&
|
||||
"[border-start-start-radius:var(--fb-input-border-radius)] [border-end-start-radius:var(--fb-input-border-radius)]",
|
||||
isLast &&
|
||||
"[border-start-end-radius:var(--fb-input-border-radius)] [border-end-end-radius:var(--fb-input-border-radius)]"
|
||||
);
|
||||
|
||||
const borderClasses = cn(
|
||||
"border-t border-b border-e", // block borders (top/bottom) and inline-end border
|
||||
isFirst && "border-s" // inline-start border for first item
|
||||
);
|
||||
|
||||
return { borderRadiusClasses, borderClasses };
|
||||
};
|
||||
|
||||
@@ -76,6 +76,7 @@ export function Survey({
|
||||
isSpamProtectionEnabled,
|
||||
dir = "auto",
|
||||
setDir,
|
||||
placement,
|
||||
}: SurveyContainerProps) {
|
||||
let apiClient: ApiClient | null = null;
|
||||
|
||||
@@ -916,6 +917,7 @@ export function Survey({
|
||||
setBlockId={setBlockId}
|
||||
shouldResetBlockId={shouldResetQuestionId}
|
||||
fullSizeCards={fullSizeCards}
|
||||
placement={placement}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MutableRef } from "preact/hooks";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { JSX } from "preact/jsx-runtime";
|
||||
import React from "react";
|
||||
import { type TPlacement } from "@formbricks/types/common";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TCardArrangementOptions } from "@formbricks/types/styling";
|
||||
|
||||
@@ -17,6 +18,7 @@ interface StackedCardProps {
|
||||
cardWidth: number;
|
||||
hovered: boolean;
|
||||
cardArrangement: TCardArrangementOptions;
|
||||
placement: TPlacement;
|
||||
}
|
||||
|
||||
export const StackedCard = ({
|
||||
@@ -31,17 +33,24 @@ export const StackedCard = ({
|
||||
cardWidth,
|
||||
hovered,
|
||||
cardArrangement,
|
||||
placement,
|
||||
}: StackedCardProps) => {
|
||||
const isHidden = offset < 0;
|
||||
const [delayedOffset, setDelayedOffset] = useState<number>(offset);
|
||||
const [contentOpacity, setContentOpacity] = useState<number>(0);
|
||||
const currentCardHeight = offset === 0 ? "auto" : offset < 0 ? "initial" : cardHeight;
|
||||
|
||||
const getBottomStyles = () => {
|
||||
const getTopBottomStyles = () => {
|
||||
if (survey.type !== "link")
|
||||
return {
|
||||
bottom: 0,
|
||||
};
|
||||
if (placement === "bottomLeft" || placement === "bottomRight") {
|
||||
return {
|
||||
bottom: 0,
|
||||
};
|
||||
} else if (placement === "topLeft" || placement === "topRight") {
|
||||
return {
|
||||
top: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getDummyCardContent = () => {
|
||||
@@ -111,7 +120,7 @@ export const StackedCard = ({
|
||||
pointerEvents: offset === 0 ? "auto" : "none",
|
||||
...borderStyles,
|
||||
...straightCardArrangementStyles,
|
||||
...getBottomStyles(),
|
||||
...getTopBottomStyles(),
|
||||
}}
|
||||
className="pointer rounded-custom bg-survey-bg absolute inset-x-0 overflow-hidden">
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { JSX } from "react";
|
||||
import { type TPlacement } from "@formbricks/types/common";
|
||||
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { type TProjectStyling } from "@formbricks/types/project";
|
||||
import { type TCardArrangementOptions } from "@formbricks/types/styling";
|
||||
@@ -19,6 +20,7 @@ interface StackedCardsContainerProps {
|
||||
setBlockId: (blockId: string) => void;
|
||||
shouldResetBlockId?: boolean;
|
||||
fullSizeCards: boolean;
|
||||
placement?: TPlacement;
|
||||
}
|
||||
|
||||
export function StackedCardsContainer({
|
||||
@@ -30,6 +32,7 @@ export function StackedCardsContainer({
|
||||
setBlockId,
|
||||
shouldResetBlockId = true,
|
||||
fullSizeCards = false,
|
||||
placement = "bottomRight",
|
||||
}: Readonly<StackedCardsContainerProps>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const highlightBorderColor = survey.styling?.overwriteThemeStyling
|
||||
@@ -179,6 +182,7 @@ export function StackedCardsContainer({
|
||||
cardWidth={cardWidth}
|
||||
hovered={hovered}
|
||||
cardArrangement={cardArrangement}
|
||||
placement={placement}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -36,18 +36,35 @@ export const renderSurvey = (props: SurveyContainerProps) => {
|
||||
throw new Error(`renderSurvey: Element with id ${containerId} not found.`);
|
||||
}
|
||||
|
||||
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
|
||||
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
|
||||
if (props.survey.type === "link") {
|
||||
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
|
||||
|
||||
render(
|
||||
h(
|
||||
I18nProvider,
|
||||
{ language },
|
||||
h(RenderSurvey, {
|
||||
...surveyInlineProps,
|
||||
})
|
||||
),
|
||||
element
|
||||
);
|
||||
render(
|
||||
h(
|
||||
I18nProvider,
|
||||
{ language },
|
||||
h(RenderSurvey, {
|
||||
...surveyInlineProps,
|
||||
})
|
||||
),
|
||||
element
|
||||
);
|
||||
} else {
|
||||
// For non-link surveys, pass placement through so it can be used in StackedCard
|
||||
const { darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
|
||||
|
||||
render(
|
||||
h(
|
||||
I18nProvider,
|
||||
{ language },
|
||||
h(RenderSurvey, {
|
||||
...surveyInlineProps,
|
||||
})
|
||||
),
|
||||
element
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const modalContainer = document.createElement("div");
|
||||
modalContainer.id = "formbricks-modal-container";
|
||||
|
||||
Generated
+155
-194
@@ -79,7 +79,7 @@ importers:
|
||||
version: 10.1.11(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||
'@storybook/addon-docs':
|
||||
specifier: 10.1.11
|
||||
version: 10.1.11(@types/react@19.2.1)(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))
|
||||
version: 10.1.11(@types/react@19.2.1)(esbuild@0.25.12)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))
|
||||
'@storybook/addon-links':
|
||||
specifier: 10.1.11
|
||||
version: 10.1.11(react@19.2.3)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||
@@ -88,7 +88,7 @@ importers:
|
||||
version: 10.1.11(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||
'@storybook/react-vite':
|
||||
specifier: 10.1.11
|
||||
version: 10.1.11(esbuild@0.27.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.8.3)(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))
|
||||
version: 10.1.11(esbuild@0.25.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.8.3)(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))
|
||||
'@tailwindcss/vite':
|
||||
specifier: 4.1.18
|
||||
version: 4.1.18(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
|
||||
@@ -102,8 +102,8 @@ importers:
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
|
||||
esbuild:
|
||||
specifier: 0.27.2
|
||||
version: 0.27.2
|
||||
specifier: 0.25.12
|
||||
version: 0.25.12
|
||||
eslint-plugin-react-refresh:
|
||||
specifier: 0.4.26
|
||||
version: 0.4.26(eslint@8.57.0)
|
||||
@@ -280,7 +280,7 @@ importers:
|
||||
version: 1.2.6(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@sentry/nextjs':
|
||||
specifier: 10.5.0
|
||||
version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.11))
|
||||
version: 10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
|
||||
'@t3-oss/env-nextjs':
|
||||
specifier: 0.13.4
|
||||
version: 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)
|
||||
@@ -325,7 +325,7 @@ importers:
|
||||
version: 4.1.0
|
||||
file-loader:
|
||||
specifier: 6.2.0
|
||||
version: 6.2.0(webpack@5.99.8(esbuild@0.25.11))
|
||||
version: 6.2.0(webpack@5.99.8(esbuild@0.25.12))
|
||||
framer-motion:
|
||||
specifier: 12.10.0
|
||||
version: 12.10.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -357,8 +357,8 @@ importers:
|
||||
specifier: 0.36.2
|
||||
version: 0.36.2
|
||||
lodash:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
specifier: 4.17.23
|
||||
version: 4.17.23
|
||||
lucide-react:
|
||||
specifier: 0.507.0
|
||||
version: 0.507.0(react@19.2.3)
|
||||
@@ -463,7 +463,7 @@ importers:
|
||||
version: 11.1.0
|
||||
webpack:
|
||||
specifier: 5.99.8
|
||||
version: 5.99.8(esbuild@0.25.11)
|
||||
version: 5.99.8(esbuild@0.25.12)
|
||||
xlsx:
|
||||
specifier: file:vendor/xlsx-0.20.3.tgz
|
||||
version: file:apps/web/vendor/xlsx-0.20.3.tgz
|
||||
@@ -535,8 +535,8 @@ importers:
|
||||
specifier: 16.5.0
|
||||
version: 16.5.0
|
||||
esbuild:
|
||||
specifier: 0.25.11
|
||||
version: 0.25.11
|
||||
specifier: 0.25.12
|
||||
version: 0.25.12
|
||||
postcss:
|
||||
specifier: 8.5.3
|
||||
version: 8.5.3
|
||||
@@ -1805,8 +1805,8 @@ packages:
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.11':
|
||||
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
@@ -1823,8 +1823,8 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
@@ -1841,8 +1841,8 @@ packages:
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.11':
|
||||
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
@@ -1859,8 +1859,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.11':
|
||||
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
@@ -1877,8 +1877,8 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
@@ -1895,8 +1895,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.11':
|
||||
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
@@ -1913,8 +1913,8 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
@@ -1931,8 +1931,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
@@ -1949,8 +1949,8 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@@ -1967,8 +1967,8 @@ packages:
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.11':
|
||||
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
@@ -1985,8 +1985,8 @@ packages:
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.11':
|
||||
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
@@ -2003,8 +2003,8 @@ packages:
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.11':
|
||||
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
@@ -2021,8 +2021,8 @@ packages:
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.11':
|
||||
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
@@ -2039,8 +2039,8 @@ packages:
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.11':
|
||||
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
@@ -2057,8 +2057,8 @@ packages:
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.11':
|
||||
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
@@ -2075,8 +2075,8 @@ packages:
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.11':
|
||||
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
@@ -2093,8 +2093,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.11':
|
||||
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@@ -2105,8 +2105,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==}
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
@@ -2123,8 +2123,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
@@ -2141,8 +2141,8 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==}
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
@@ -2159,8 +2159,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.11':
|
||||
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
@@ -2171,8 +2171,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==}
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
@@ -2189,8 +2189,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.11':
|
||||
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
@@ -2207,8 +2207,8 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.11':
|
||||
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
@@ -2225,8 +2225,8 @@ packages:
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.11':
|
||||
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
@@ -2243,8 +2243,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -6943,8 +6943,8 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.25.11:
|
||||
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -8321,6 +8321,9 @@ packages:
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
log-symbols@2.2.0:
|
||||
resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -12920,7 +12923,7 @@ snapshots:
|
||||
'@esbuild/aix-ppc64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.11':
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
@@ -12929,7 +12932,7 @@ snapshots:
|
||||
'@esbuild/android-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.11':
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.2':
|
||||
@@ -12938,7 +12941,7 @@ snapshots:
|
||||
'@esbuild/android-arm@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.11':
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.2':
|
||||
@@ -12947,7 +12950,7 @@ snapshots:
|
||||
'@esbuild/android-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.11':
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.2':
|
||||
@@ -12956,7 +12959,7 @@ snapshots:
|
||||
'@esbuild/darwin-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.11':
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.2':
|
||||
@@ -12965,7 +12968,7 @@ snapshots:
|
||||
'@esbuild/darwin-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.11':
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.2':
|
||||
@@ -12974,7 +12977,7 @@ snapshots:
|
||||
'@esbuild/freebsd-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.11':
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.2':
|
||||
@@ -12983,7 +12986,7 @@ snapshots:
|
||||
'@esbuild/freebsd-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.11':
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.2':
|
||||
@@ -12992,7 +12995,7 @@ snapshots:
|
||||
'@esbuild/linux-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.11':
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.2':
|
||||
@@ -13001,7 +13004,7 @@ snapshots:
|
||||
'@esbuild/linux-arm@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.11':
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.2':
|
||||
@@ -13010,7 +13013,7 @@ snapshots:
|
||||
'@esbuild/linux-ia32@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.11':
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.2':
|
||||
@@ -13019,7 +13022,7 @@ snapshots:
|
||||
'@esbuild/linux-loong64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.11':
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.2':
|
||||
@@ -13028,7 +13031,7 @@ snapshots:
|
||||
'@esbuild/linux-mips64el@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.11':
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.2':
|
||||
@@ -13037,7 +13040,7 @@ snapshots:
|
||||
'@esbuild/linux-ppc64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.11':
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.2':
|
||||
@@ -13046,7 +13049,7 @@ snapshots:
|
||||
'@esbuild/linux-riscv64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.11':
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.2':
|
||||
@@ -13055,7 +13058,7 @@ snapshots:
|
||||
'@esbuild/linux-s390x@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.11':
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.2':
|
||||
@@ -13064,13 +13067,13 @@ snapshots:
|
||||
'@esbuild/linux-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.11':
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.11':
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.2':
|
||||
@@ -13079,7 +13082,7 @@ snapshots:
|
||||
'@esbuild/netbsd-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.11':
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.2':
|
||||
@@ -13088,7 +13091,7 @@ snapshots:
|
||||
'@esbuild/openbsd-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.11':
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.2':
|
||||
@@ -13097,13 +13100,13 @@ snapshots:
|
||||
'@esbuild/openbsd-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.11':
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.11':
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.2':
|
||||
@@ -13112,7 +13115,7 @@ snapshots:
|
||||
'@esbuild/sunos-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.11':
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.2':
|
||||
@@ -13121,7 +13124,7 @@ snapshots:
|
||||
'@esbuild/win32-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.11':
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.2':
|
||||
@@ -13130,7 +13133,7 @@ snapshots:
|
||||
'@esbuild/win32-ia32@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.11':
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.2':
|
||||
@@ -13139,7 +13142,7 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
@@ -13742,7 +13745,7 @@ snapshots:
|
||||
'@rushstack/terminal': 0.19.5(@types/node@22.15.18)
|
||||
'@rushstack/ts-command-line': 5.1.5(@types/node@22.15.18)
|
||||
diff: 8.0.2
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
minimatch: 10.0.3
|
||||
resolve: 1.22.11
|
||||
semver: 7.5.4
|
||||
@@ -15693,7 +15696,7 @@ snapshots:
|
||||
|
||||
'@sentry/core@10.5.0': {}
|
||||
|
||||
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.11))':
|
||||
'@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.38.0
|
||||
@@ -15704,7 +15707,7 @@ snapshots:
|
||||
'@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
|
||||
'@sentry/react': 10.5.0(react@19.2.3)
|
||||
'@sentry/vercel-edge': 10.5.0
|
||||
'@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.11))
|
||||
'@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.12))
|
||||
chalk: 3.0.0
|
||||
next: 16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
resolve: 1.22.8
|
||||
@@ -15794,12 +15797,12 @@ snapshots:
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
|
||||
'@sentry/core': 10.5.0
|
||||
|
||||
'@sentry/webpack-plugin@4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.11))':
|
||||
'@sentry/webpack-plugin@4.6.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.12))':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 4.6.1(encoding@0.1.13)
|
||||
unplugin: 1.0.1
|
||||
uuid: 11.1.0
|
||||
webpack: 5.99.8(esbuild@0.25.11)
|
||||
webpack: 5.99.8(esbuild@0.25.12)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -16161,10 +16164,10 @@ snapshots:
|
||||
axe-core: 4.11.0
|
||||
storybook: 10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
||||
'@storybook/addon-docs@10.1.11(@types/react@19.2.1)(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))':
|
||||
'@storybook/addon-docs@10.1.11(@types/react@19.2.1)(esbuild@0.25.12)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.2.1)(react@19.2.3)
|
||||
'@storybook/csf-plugin': 10.1.11(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))
|
||||
'@storybook/csf-plugin': 10.1.11(esbuild@0.25.12)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))
|
||||
'@storybook/icons': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@storybook/react-dom-shim': 10.1.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||
react: 19.2.3
|
||||
@@ -16189,9 +16192,9 @@ snapshots:
|
||||
dependencies:
|
||||
storybook: 10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
||||
'@storybook/builder-vite@10.1.11(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))':
|
||||
'@storybook/builder-vite@10.1.11(esbuild@0.25.12)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.1.11(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))
|
||||
'@storybook/csf-plugin': 10.1.11(esbuild@0.25.12)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
|
||||
storybook: 10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
ts-dedent: 2.2.0
|
||||
@@ -16214,15 +16217,15 @@ snapshots:
|
||||
dependencies:
|
||||
storybook: 10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
|
||||
'@storybook/csf-plugin@10.1.11(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))':
|
||||
'@storybook/csf-plugin@10.1.11(esbuild@0.25.12)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))':
|
||||
dependencies:
|
||||
storybook: 10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
unplugin: 2.3.11
|
||||
optionalDependencies:
|
||||
esbuild: 0.27.2
|
||||
esbuild: 0.25.12
|
||||
rollup: 4.54.0
|
||||
vite: 7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2)
|
||||
webpack: 5.99.8(esbuild@0.27.2)
|
||||
webpack: 5.99.8(esbuild@0.25.12)
|
||||
|
||||
'@storybook/csf-plugin@8.5.4(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))':
|
||||
dependencies:
|
||||
@@ -16261,11 +16264,11 @@ snapshots:
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
storybook: 10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
|
||||
'@storybook/react-vite@10.1.11(esbuild@0.27.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.8.3)(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))':
|
||||
'@storybook/react-vite@10.1.11(esbuild@0.25.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.8.3)(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))':
|
||||
dependencies:
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.8.3)(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.54.0)
|
||||
'@storybook/builder-vite': 10.1.11(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.27.2))
|
||||
'@storybook/builder-vite': 10.1.11(esbuild@0.25.12)(rollup@4.54.0)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2))(webpack@5.99.8(esbuild@0.25.12))
|
||||
'@storybook/react': 10.1.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@8.20.1)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.8.3)
|
||||
empathic: 2.0.0
|
||||
magic-string: 0.30.21
|
||||
@@ -16551,7 +16554,7 @@ snapshots:
|
||||
chalk: 3.0.0
|
||||
css.escape: 1.5.1
|
||||
dom-accessibility-api: 0.6.3
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
redent: 3.0.0
|
||||
|
||||
'@testing-library/preact@3.2.4(preact@10.28.2)':
|
||||
@@ -16583,7 +16586,7 @@ snapshots:
|
||||
'@babel/traverse': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
javascript-natural-sort: 0.7.1
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
prettier: 3.5.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18144,7 +18147,7 @@ snapshots:
|
||||
concurrently@9.1.2:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
rxjs: 7.8.2
|
||||
shell-quote: 1.8.3
|
||||
supports-color: 8.1.1
|
||||
@@ -18697,34 +18700,34 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.23.1
|
||||
'@esbuild/win32-x64': 0.23.1
|
||||
|
||||
esbuild@0.25.11:
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.11
|
||||
'@esbuild/android-arm': 0.25.11
|
||||
'@esbuild/android-arm64': 0.25.11
|
||||
'@esbuild/android-x64': 0.25.11
|
||||
'@esbuild/darwin-arm64': 0.25.11
|
||||
'@esbuild/darwin-x64': 0.25.11
|
||||
'@esbuild/freebsd-arm64': 0.25.11
|
||||
'@esbuild/freebsd-x64': 0.25.11
|
||||
'@esbuild/linux-arm': 0.25.11
|
||||
'@esbuild/linux-arm64': 0.25.11
|
||||
'@esbuild/linux-ia32': 0.25.11
|
||||
'@esbuild/linux-loong64': 0.25.11
|
||||
'@esbuild/linux-mips64el': 0.25.11
|
||||
'@esbuild/linux-ppc64': 0.25.11
|
||||
'@esbuild/linux-riscv64': 0.25.11
|
||||
'@esbuild/linux-s390x': 0.25.11
|
||||
'@esbuild/linux-x64': 0.25.11
|
||||
'@esbuild/netbsd-arm64': 0.25.11
|
||||
'@esbuild/netbsd-x64': 0.25.11
|
||||
'@esbuild/openbsd-arm64': 0.25.11
|
||||
'@esbuild/openbsd-x64': 0.25.11
|
||||
'@esbuild/openharmony-arm64': 0.25.11
|
||||
'@esbuild/sunos-x64': 0.25.11
|
||||
'@esbuild/win32-arm64': 0.25.11
|
||||
'@esbuild/win32-ia32': 0.25.11
|
||||
'@esbuild/win32-x64': 0.25.11
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
'@esbuild/android-arm': 0.25.12
|
||||
'@esbuild/android-arm64': 0.25.12
|
||||
'@esbuild/android-x64': 0.25.12
|
||||
'@esbuild/darwin-arm64': 0.25.12
|
||||
'@esbuild/darwin-x64': 0.25.12
|
||||
'@esbuild/freebsd-arm64': 0.25.12
|
||||
'@esbuild/freebsd-x64': 0.25.12
|
||||
'@esbuild/linux-arm': 0.25.12
|
||||
'@esbuild/linux-arm64': 0.25.12
|
||||
'@esbuild/linux-ia32': 0.25.12
|
||||
'@esbuild/linux-loong64': 0.25.12
|
||||
'@esbuild/linux-mips64el': 0.25.12
|
||||
'@esbuild/linux-ppc64': 0.25.12
|
||||
'@esbuild/linux-riscv64': 0.25.12
|
||||
'@esbuild/linux-s390x': 0.25.12
|
||||
'@esbuild/linux-x64': 0.25.12
|
||||
'@esbuild/netbsd-arm64': 0.25.12
|
||||
'@esbuild/netbsd-x64': 0.25.12
|
||||
'@esbuild/openbsd-arm64': 0.25.12
|
||||
'@esbuild/openbsd-x64': 0.25.12
|
||||
'@esbuild/openharmony-arm64': 0.25.12
|
||||
'@esbuild/sunos-x64': 0.25.12
|
||||
'@esbuild/win32-arm64': 0.25.12
|
||||
'@esbuild/win32-ia32': 0.25.12
|
||||
'@esbuild/win32-x64': 0.25.12
|
||||
|
||||
esbuild@0.27.2:
|
||||
optionalDependencies:
|
||||
@@ -18858,7 +18861,7 @@ snapshots:
|
||||
eslint: 8.57.0
|
||||
indent-string: 3.2.0
|
||||
jest-diff: 22.4.3
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
log-symbols: 2.2.0
|
||||
parse-json: 5.2.0
|
||||
plur: 2.1.2
|
||||
@@ -19221,11 +19224,11 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
file-loader@6.2.0(webpack@5.99.8(esbuild@0.25.11)):
|
||||
file-loader@6.2.0(webpack@5.99.8(esbuild@0.25.12)):
|
||||
dependencies:
|
||||
loader-utils: 2.0.4
|
||||
schema-utils: 3.3.0
|
||||
webpack: 5.99.8(esbuild@0.25.11)
|
||||
webpack: 5.99.8(esbuild@0.25.12)
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
@@ -20279,6 +20282,8 @@ snapshots:
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
log-symbols@2.2.0:
|
||||
dependencies:
|
||||
chalk: 2.4.2
|
||||
@@ -21436,7 +21441,7 @@ snapshots:
|
||||
commander: 13.1.0
|
||||
conf: 15.0.2
|
||||
debounce: 2.2.0
|
||||
esbuild: 0.25.11
|
||||
esbuild: 0.25.12
|
||||
glob: 11.1.0
|
||||
jiti: 2.4.2
|
||||
log-symbols: 7.0.1
|
||||
@@ -22231,7 +22236,7 @@ snapshots:
|
||||
'@testing-library/user-event': 14.6.1(@testing-library/dom@8.20.1)
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
esbuild: 0.27.2
|
||||
esbuild: 0.25.12
|
||||
open: 10.2.0
|
||||
recast: 0.23.11
|
||||
semver: 7.7.3
|
||||
@@ -22254,7 +22259,7 @@ snapshots:
|
||||
'@testing-library/user-event': 14.6.1(@testing-library/dom@8.20.1)
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
esbuild: 0.27.2
|
||||
esbuild: 0.25.12
|
||||
open: 10.2.0
|
||||
recast: 0.23.11
|
||||
semver: 7.7.3
|
||||
@@ -22513,28 +22518,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
terser-webpack-plugin@5.3.16(esbuild@0.25.11)(webpack@5.99.8(esbuild@0.25.11)):
|
||||
terser-webpack-plugin@5.3.16(esbuild@0.25.12)(webpack@5.99.8(esbuild@0.25.12)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
terser: 5.39.1
|
||||
webpack: 5.99.8(esbuild@0.25.11)
|
||||
webpack: 5.99.8(esbuild@0.25.12)
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.11
|
||||
|
||||
terser-webpack-plugin@5.3.16(esbuild@0.27.2)(webpack@5.99.8(esbuild@0.27.2)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
terser: 5.39.1
|
||||
webpack: 5.99.8(esbuild@0.27.2)
|
||||
optionalDependencies:
|
||||
esbuild: 0.27.2
|
||||
optional: true
|
||||
esbuild: 0.25.12
|
||||
|
||||
terser@5.39.1:
|
||||
dependencies:
|
||||
@@ -22699,7 +22692,7 @@ snapshots:
|
||||
|
||||
tsx@4.19.4:
|
||||
dependencies:
|
||||
esbuild: 0.25.11
|
||||
esbuild: 0.25.12
|
||||
get-tsconfig: 4.13.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
@@ -23083,7 +23076,7 @@ snapshots:
|
||||
|
||||
vite@6.4.1(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.11
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.3
|
||||
@@ -23100,7 +23093,7 @@ snapshots:
|
||||
|
||||
vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.2)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.11
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.3
|
||||
@@ -23117,7 +23110,7 @@ snapshots:
|
||||
|
||||
vite@6.4.1(@types/node@22.15.18)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.11
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.3
|
||||
@@ -23266,7 +23259,7 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webpack@5.99.8(esbuild@0.25.11):
|
||||
webpack@5.99.8(esbuild@0.25.12):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.8
|
||||
@@ -23289,7 +23282,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.0
|
||||
terser-webpack-plugin: 5.3.16(esbuild@0.25.11)(webpack@5.99.8(esbuild@0.25.11))
|
||||
terser-webpack-plugin: 5.3.16(esbuild@0.25.12)(webpack@5.99.8(esbuild@0.25.12))
|
||||
watchpack: 2.4.4
|
||||
webpack-sources: 3.3.3
|
||||
transitivePeerDependencies:
|
||||
@@ -23297,38 +23290,6 @@ snapshots:
|
||||
- esbuild
|
||||
- uglify-js
|
||||
|
||||
webpack@5.99.8(esbuild@0.27.2):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.8
|
||||
'@types/json-schema': 7.0.15
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
'@webassemblyjs/wasm-edit': 1.14.1
|
||||
'@webassemblyjs/wasm-parser': 1.14.1
|
||||
acorn: 8.15.0
|
||||
browserslist: 4.28.1
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.18.4
|
||||
es-module-lexer: 1.7.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
glob-to-regexp: 0.4.1
|
||||
graceful-fs: 4.2.11
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
loader-runner: 4.3.1
|
||||
mime-types: 2.1.35
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.0
|
||||
terser-webpack-plugin: 5.3.16(esbuild@0.27.2)(webpack@5.99.8(esbuild@0.27.2))
|
||||
watchpack: 2.4.4
|
||||
webpack-sources: 3.3.3
|
||||
transitivePeerDependencies:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
- uglify-js
|
||||
optional: true
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
|
||||
Reference in New Issue
Block a user