mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-31 01:40:32 -05:00
Compare commits
3 Commits
typeerror-
...
feat/unify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590c85d1ca | ||
|
|
39c99baaac | ||
|
|
238b2adf3f |
@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
// Calculate derived values (no queries)
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
@@ -63,7 +63,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
export type AddKeywordModalLevel = "L1" | "L2" | "L3";
|
||||
|
||||
interface AddKeywordModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
level: AddKeywordModalLevel;
|
||||
parentName?: string;
|
||||
onConfirm: (name: string) => void;
|
||||
}
|
||||
|
||||
export function AddKeywordModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
level,
|
||||
parentName,
|
||||
onConfirm,
|
||||
}: AddKeywordModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const handleClose = (nextOpen: boolean) => {
|
||||
if (!nextOpen) setName("");
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Please enter a keyword name.");
|
||||
return;
|
||||
}
|
||||
onConfirm(trimmed);
|
||||
setName("");
|
||||
onOpenChange(false);
|
||||
toast.success("Keyword added (demo).");
|
||||
};
|
||||
|
||||
const title =
|
||||
level === "L1" ? "Add L1 keyword" : level === "L2" ? "Add L2 keyword" : "Add L3 keyword";
|
||||
const description =
|
||||
level === "L1"
|
||||
? "Add a new top-level keyword."
|
||||
: parentName
|
||||
? `Add a new ${level} keyword under "${parentName}".`
|
||||
: `Add a new ${level} keyword.`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyword-name">Keyword name</Label>
|
||||
<Input
|
||||
id="keyword-name"
|
||||
placeholder="e.g. New category"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter className="m-2">
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add keyword</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LightbulbIcon,
|
||||
MessageCircleIcon,
|
||||
TriangleAlertIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { H4, Small } from "@/modules/ui/components/typography";
|
||||
import type { TaxonomyDetail, TaxonomyThemeItem } from "../types";
|
||||
|
||||
const THEME_COLORS: Record<string, string> = {
|
||||
red: "bg-red-400",
|
||||
orange: "bg-orange-400",
|
||||
yellow: "bg-amber-400",
|
||||
green: "bg-emerald-500",
|
||||
slate: "bg-slate-400",
|
||||
};;
|
||||
|
||||
function getThemeIcon(icon?: TaxonomyThemeItem["icon"]) {
|
||||
switch (icon) {
|
||||
case "warning":
|
||||
return <TriangleAlertIcon className="size-4 text-amber-500" />;
|
||||
case "wrench":
|
||||
return <WrenchIcon className="size-4 text-slate-500" />;
|
||||
case "message-circle":
|
||||
return <MessageCircleIcon className="size-4 text-slate-500" />;
|
||||
case "lightbulb":
|
||||
return <LightbulbIcon className="size-4 text-amber-500" />;
|
||||
default:
|
||||
return <MessageCircleIcon className="size-4 text-slate-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeItemRowProps {
|
||||
item: TaxonomyThemeItem;
|
||||
depth?: number;
|
||||
themeSearch: string;
|
||||
}
|
||||
|
||||
function ThemeItemRow({ item, depth = 0, themeSearch }: ThemeItemRowProps) {
|
||||
const [expanded, setExpanded] = useState(depth === 0 && (item.children?.length ?? 0) > 0);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const labelLower = item.label.toLowerCase();
|
||||
const matchesSearch =
|
||||
!themeSearch.trim() || labelLower.includes(themeSearch.trim().toLowerCase());
|
||||
const childMatches =
|
||||
hasChildren &&
|
||||
item.children!.some((c) =>
|
||||
c.label.toLowerCase().includes(themeSearch.trim().toLowerCase())
|
||||
);
|
||||
const show = matchesSearch || childMatches;
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 text-sm",
|
||||
depth === 0 ? "font-medium text-slate-800" : "text-slate-600"
|
||||
)}
|
||||
style={{ paddingLeft: depth * 16 + 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||
className="flex shrink-0 items-center justify-center text-slate-400 hover:text-slate-600">
|
||||
{hasChildren ? (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform", expanded && "rotate-90")}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
</button>
|
||||
{getThemeIcon(item.icon)}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<Small color="muted" className="shrink-0">
|
||||
{item.count}
|
||||
</Small>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div className="border-l border-slate-200 pl-2">
|
||||
{item.children!.map((child) => (
|
||||
<ThemeItemRow
|
||||
key={child.id}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
themeSearch={themeSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaxonomyDetailPanelProps {
|
||||
detail: TaxonomyDetail | null;
|
||||
}
|
||||
|
||||
export function TaxonomyDetailPanel({ detail }: TaxonomyDetailPanelProps) {
|
||||
const [themeSearch, setThemeSearch] = useState("");
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||
<Small color="muted">Select a Level 3 keyword to view details.</Small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalThemes = detail.themes.reduce((s, t) => s + t.count, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-col gap-5 overflow-y-auto p-4">
|
||||
<div className="border-b border-slate-200 pb-4">
|
||||
<H4 className="mb-1">{detail.keywordName}</H4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Small color="muted">{detail.count} responses</Small>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-slate-600 underline-offset-2 hover:underline">
|
||||
View all →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<H4 className="mb-1 text-sm">Description</H4>
|
||||
<Small color="muted" className="leading-relaxed">
|
||||
{detail.description}
|
||||
</Small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<H4 className="text-sm">{detail.themes.length} themes</H4>
|
||||
<div className="flex h-2 flex-1 max-w-[120px] overflow-hidden rounded-full bg-slate-100">
|
||||
{detail.themes.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(THEME_COLORS[t.color] ?? "bg-slate-400")}
|
||||
style={{
|
||||
width: totalThemes ? `${(t.count / totalThemes) * 100}%` : "0%",
|
||||
}}
|
||||
title={t.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search themes"
|
||||
value={themeSearch}
|
||||
onChange={(e) => setThemeSearch(e.target.value)}
|
||||
className="mb-3 h-9 text-sm"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
{detail.themeItems.map((item) => (
|
||||
<ThemeItemRow key={item.id} item={item} themeSearch={themeSearch} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatCount } from "../lib/mock-data";
|
||||
import type { TaxonomyKeyword } from "../types";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface TaxonomyKeywordColumnProps {
|
||||
title: string;
|
||||
keywords: TaxonomyKeyword[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
addButtonLabel?: string;
|
||||
onAdd?: () => void;
|
||||
}
|
||||
|
||||
export function TaxonomyKeywordColumn({
|
||||
title,
|
||||
keywords,
|
||||
selectedId,
|
||||
onSelect,
|
||||
addButtonLabel,
|
||||
onAdd,
|
||||
}: TaxonomyKeywordColumnProps) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
|
||||
</div>
|
||||
<div className="flex min-h-[320px] flex-1 flex-col overflow-y-auto">
|
||||
{keywords.map((kw) => (
|
||||
<button
|
||||
key={kw.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(kw.id)}
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr,auto] content-center gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors last:border-b-0",
|
||||
selectedId === kw.id ? "bg-slate-50" : "hover:bg-slate-50"
|
||||
)}>
|
||||
<span className="min-w-0 truncate text-sm font-medium text-slate-800">{kw.name}</span>
|
||||
<span className="text-sm text-slate-500">{formatCount(kw.count)}</span>
|
||||
</button>
|
||||
))}
|
||||
{addButtonLabel && (
|
||||
<div className="border-t border-slate-200 p-2">
|
||||
<Button type="button" variant="outline" size="sm" className="w-full" onClick={onAdd}>
|
||||
+ {addButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { getDetailForL3, getL2Keywords, getL3Keywords, MOCK_LEVEL1_KEYWORDS } from "../lib/mock-data";
|
||||
import type { TaxonomyKeyword } from "../types";
|
||||
import { AddKeywordModal } from "./AddKeywordModal";
|
||||
import { TaxonomyDetailPanel } from "./TaxonomyDetailPanel";
|
||||
import { TaxonomyKeywordColumn } from "./TaxonomyKeywordColumn";
|
||||
|
||||
interface TaxonomySectionProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function TaxonomySection({ environmentId }: TaxonomySectionProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedL1Id, setSelectedL1Id] = useState<string | null>("l1-1");
|
||||
const [selectedL2Id, setSelectedL2Id] = useState<string | null>("l2-1a");
|
||||
const [selectedL3Id, setSelectedL3Id] = useState<string | null>("l3-1a");
|
||||
const [addKeywordModalOpen, setAddKeywordModalOpen] = useState(false);
|
||||
const [addKeywordModalLevel, setAddKeywordModalLevel] = useState<"L1" | "L2" | "L3">("L1");
|
||||
const [customL1Keywords, setCustomL1Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||
const [customL2Keywords, setCustomL2Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||
const [customL3Keywords, setCustomL3Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||
|
||||
const l2Keywords = useMemo(() => {
|
||||
const fromMock = selectedL1Id ? getL2Keywords(selectedL1Id) : [];
|
||||
const custom = customL2Keywords.filter((k) => k.parentId === selectedL1Id);
|
||||
return [...fromMock, ...custom];
|
||||
}, [selectedL1Id, customL2Keywords]);
|
||||
|
||||
const l3Keywords = useMemo(() => {
|
||||
const fromMock = selectedL2Id ? getL3Keywords(selectedL2Id) : [];
|
||||
const custom = customL3Keywords.filter((k) => k.parentId === selectedL2Id);
|
||||
return [...fromMock, ...custom];
|
||||
}, [selectedL2Id, customL3Keywords]);
|
||||
const detail = useMemo(
|
||||
() => (selectedL3Id ? getDetailForL3(selectedL3Id) : null),
|
||||
[selectedL3Id]
|
||||
);
|
||||
|
||||
const l1Keywords = useMemo(
|
||||
() => [...MOCK_LEVEL1_KEYWORDS, ...customL1Keywords],
|
||||
[customL1Keywords]
|
||||
);
|
||||
|
||||
const filterKeywords = (list: TaxonomyKeyword[], q: string) => {
|
||||
if (!q.trim()) return list;
|
||||
const lower = q.trim().toLowerCase();
|
||||
return list.filter((k) => k.name.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const filteredL1 = useMemo(
|
||||
() => filterKeywords(l1Keywords, searchQuery),
|
||||
[l1Keywords, searchQuery]
|
||||
);
|
||||
const filteredL2 = useMemo(() => filterKeywords(l2Keywords, searchQuery), [l2Keywords, searchQuery]);
|
||||
const filteredL3 = useMemo(() => filterKeywords(l3Keywords, searchQuery), [l3Keywords, searchQuery]);
|
||||
|
||||
const selectedL1Name = useMemo(
|
||||
() => l1Keywords.find((k) => k.id === selectedL1Id)?.name,
|
||||
[l1Keywords, selectedL1Id]
|
||||
);
|
||||
const selectedL2Name = useMemo(
|
||||
() => l2Keywords.find((k) => k.id === selectedL2Id)?.name,
|
||||
[l2Keywords, selectedL2Id]
|
||||
);
|
||||
|
||||
const addKeywordParentName =
|
||||
addKeywordModalLevel === "L2" ? selectedL1Name : addKeywordModalLevel === "L3" ? selectedL2Name : undefined;
|
||||
|
||||
const handleAddKeyword = (name: string) => {
|
||||
if (addKeywordModalLevel === "L1") {
|
||||
const id = `custom-l1-${crypto.randomUUID()}`;
|
||||
setCustomL1Keywords((prev) => [...prev, { id, name, count: 0 }]);
|
||||
setSelectedL1Id(id);
|
||||
setSelectedL2Id(null);
|
||||
setSelectedL3Id(null);
|
||||
} else if (addKeywordModalLevel === "L2" && selectedL1Id) {
|
||||
const id = `custom-l2-${crypto.randomUUID()}`;
|
||||
setCustomL2Keywords((prev) => [
|
||||
...prev,
|
||||
{ id, name, count: 0, parentId: selectedL1Id },
|
||||
]);
|
||||
setSelectedL2Id(id);
|
||||
setSelectedL3Id(null);
|
||||
} else if (addKeywordModalLevel === "L3" && selectedL2Id) {
|
||||
const id = `custom-l3-${crypto.randomUUID()}`;
|
||||
setCustomL3Keywords((prev) => [
|
||||
...prev,
|
||||
{ id, name, count: 0, parentId: selectedL2Id },
|
||||
]);
|
||||
setSelectedL3Id(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Unify Feedback">
|
||||
<UnifyConfigNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Find in taxonomy..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<div className="lg:col-span-3">
|
||||
<TaxonomyKeywordColumn
|
||||
title={`Level 1 Keywords (${filteredL1.length})`}
|
||||
keywords={filteredL1}
|
||||
selectedId={selectedL1Id}
|
||||
onSelect={(id) => {
|
||||
setSelectedL1Id(id);
|
||||
setSelectedL2Id(null);
|
||||
setSelectedL3Id(null);
|
||||
}}
|
||||
addButtonLabel="Add L1 Keyword"
|
||||
onAdd={() => {
|
||||
setAddKeywordModalLevel("L1");
|
||||
setAddKeywordModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-3">
|
||||
<TaxonomyKeywordColumn
|
||||
title={`Level 2 Keywords (${filteredL2.length})`}
|
||||
keywords={filteredL2}
|
||||
selectedId={selectedL2Id}
|
||||
onSelect={(id) => {
|
||||
setSelectedL2Id(id);
|
||||
setSelectedL3Id(null);
|
||||
}}
|
||||
addButtonLabel="Add L2 Keyword"
|
||||
onAdd={() => {
|
||||
setAddKeywordModalLevel("L2");
|
||||
setAddKeywordModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-3">
|
||||
<TaxonomyKeywordColumn
|
||||
title={`Level 3 Keywords (${filteredL3.length})`}
|
||||
keywords={filteredL3}
|
||||
selectedId={selectedL3Id}
|
||||
onSelect={setSelectedL3Id}
|
||||
addButtonLabel="Add L3 Keyword"
|
||||
onAdd={() => {
|
||||
setAddKeywordModalLevel("L3");
|
||||
setAddKeywordModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-[400px] lg:col-span-3">
|
||||
<TaxonomyDetailPanel detail={detail} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddKeywordModal
|
||||
open={addKeywordModalOpen}
|
||||
onOpenChange={setAddKeywordModalOpen}
|
||||
level={addKeywordModalLevel}
|
||||
parentName={addKeywordParentName}
|
||||
onConfirm={handleAddKeyword}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { TaxonomyDetail, TaxonomyKeyword, TaxonomyThemeItem } from "../types";
|
||||
|
||||
export const MOCK_LEVEL1_KEYWORDS: TaxonomyKeyword[] = [
|
||||
{ id: "l1-1", name: "Dashboard", count: 12400 },
|
||||
{ id: "l1-2", name: "Usability", count: 8200 },
|
||||
{ id: "l1-3", name: "Performance", count: 5600 },
|
||||
{ id: "l1-4", name: "Miscellaneous", count: 3100 },
|
||||
];
|
||||
|
||||
export const MOCK_LEVEL2_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
|
||||
"l1-1": [
|
||||
{ id: "l2-1a", name: "Survey overview", count: 5200, parentId: "l1-1" },
|
||||
{ id: "l2-2a", name: "Response metrics", count: 3800, parentId: "l1-1" },
|
||||
{ id: "l2-3a", name: "Analytics & reports", count: 2400, parentId: "l1-1" },
|
||||
{ id: "l2-4a", name: "Widgets & embedding", count: 800, parentId: "l1-1" },
|
||||
{ id: "l2-5a", name: "Not specified", count: 200, parentId: "l1-1" },
|
||||
],
|
||||
"l1-2": [
|
||||
{ id: "l2-1b", name: "Survey builder", count: 3200, parentId: "l1-2" },
|
||||
{ id: "l2-2b", name: "Question types", count: 2100, parentId: "l1-2" },
|
||||
{ id: "l2-3b", name: "Logic & branching", count: 1400, parentId: "l1-2" },
|
||||
{ id: "l2-4b", name: "Styling & theming", count: 900, parentId: "l1-2" },
|
||||
{ id: "l2-5b", name: "Not specified", count: 600, parentId: "l1-2" },
|
||||
],
|
||||
"l1-3": [
|
||||
{ id: "l2-1c", name: "Load time & speed", count: 2200, parentId: "l1-3" },
|
||||
{ id: "l2-2c", name: "Survey rendering", count: 1600, parentId: "l1-3" },
|
||||
{ id: "l2-3c", name: "SDK & integration", count: 1100, parentId: "l1-3" },
|
||||
{ id: "l2-4c", name: "API & data sync", count: 500, parentId: "l1-3" },
|
||||
{ id: "l2-5c", name: "Not specified", count: 200, parentId: "l1-3" },
|
||||
],
|
||||
"l1-4": [
|
||||
{ id: "l2-1d", name: "Feature requests", count: 1500, parentId: "l1-4" },
|
||||
{ id: "l2-2d", name: "Bug reports", count: 900, parentId: "l1-4" },
|
||||
{ id: "l2-3d", name: "Documentation", count: 400, parentId: "l1-4" },
|
||||
{ id: "l2-4d", name: "Not specified", count: 300, parentId: "l1-4" },
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_LEVEL3_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
|
||||
"l2-1a": [
|
||||
{ id: "l3-1a", name: "In-app surveys", count: 2800, parentId: "l2-1a" },
|
||||
{ id: "l3-2a", name: "Link surveys", count: 1600, parentId: "l2-1a" },
|
||||
{ id: "l3-3a", name: "Response summary", count: 600, parentId: "l2-1a" },
|
||||
{ id: "l3-4a", name: "Not specified", count: 200, parentId: "l2-1a" },
|
||||
],
|
||||
"l2-2a": [
|
||||
{ id: "l3-5a", name: "Completion rate", count: 1800, parentId: "l2-2a" },
|
||||
{ id: "l3-6a", name: "Drop-off points", count: 1200, parentId: "l2-2a" },
|
||||
{ id: "l3-7a", name: "Response distribution", count: 800, parentId: "l2-2a" },
|
||||
],
|
||||
"l2-1b": [
|
||||
{ id: "l3-1b", name: "Drag & drop editor", count: 1400, parentId: "l2-1b" },
|
||||
{ id: "l3-2b", name: "Question configuration", count: 900, parentId: "l2-1b" },
|
||||
{ id: "l3-3b", name: "Multi-language surveys", count: 500, parentId: "l2-1b" },
|
||||
{ id: "l3-4b", name: "Not specified", count: 400, parentId: "l2-1b" },
|
||||
],
|
||||
"l2-2b": [
|
||||
{ id: "l3-5b", name: "Open text & NPS", count: 1100, parentId: "l2-2b" },
|
||||
{ id: "l3-6b", name: "Multiple choice & rating", count: 600, parentId: "l2-2b" },
|
||||
{ id: "l3-7b", name: "File upload & date picker", count: 400, parentId: "l2-2b" },
|
||||
],
|
||||
"l2-1c": [
|
||||
{ id: "l3-1c", name: "Widget initialization", count: 900, parentId: "l2-1c" },
|
||||
{ id: "l3-2c", name: "Survey load delay", count: 700, parentId: "l2-1c" },
|
||||
{ id: "l3-3c", name: "Bundle size impact", count: 600, parentId: "l2-1c" },
|
||||
],
|
||||
"l2-1d": [
|
||||
{ id: "l3-1d", name: "New question types", count: 600, parentId: "l2-1d" },
|
||||
{ id: "l3-2d", name: "Integrations & webhooks", count: 500, parentId: "l2-1d" },
|
||||
{ id: "l3-3d", name: "Export & reporting", count: 400, parentId: "l2-1d" },
|
||||
],
|
||||
};
|
||||
|
||||
export function getL2Keywords(parentL1Id: string): TaxonomyKeyword[] {
|
||||
return MOCK_LEVEL2_KEYWORDS[parentL1Id] ?? [];
|
||||
}
|
||||
|
||||
export function getL3Keywords(parentL2Id: string): TaxonomyKeyword[] {
|
||||
return MOCK_LEVEL3_KEYWORDS[parentL2Id] ?? [];
|
||||
}
|
||||
|
||||
export const MOCK_DETAIL_L3: Record<string, TaxonomyDetail> = {
|
||||
"l3-1a": {
|
||||
keywordId: "l3-1a",
|
||||
keywordName: "In-app surveys",
|
||||
count: 2800,
|
||||
description:
|
||||
"Feedback collected directly inside your product. Formbricks in-app surveys are triggered by actions (e.g. page view, click) and can be shown as modal, full-width, or inline widgets.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Issues", count: 1200, color: "red" },
|
||||
{ id: "t2", label: "Ideas", count: 900, color: "orange" },
|
||||
{ id: "t3", label: "Questions", count: 500, color: "yellow" },
|
||||
{ id: "t4", label: "Other", count: 200, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{
|
||||
id: "ti-1",
|
||||
label: "Survey not showing on trigger",
|
||||
count: 420,
|
||||
icon: "warning",
|
||||
children: [
|
||||
{ id: "ti-1-1", label: "Wrong environment or survey ID", count: 200 },
|
||||
{ id: "ti-1-2", label: "Trigger conditions not met", count: 150 },
|
||||
{ id: "ti-1-3", label: "SDK not loaded in time", count: 70 },
|
||||
],
|
||||
},
|
||||
{ id: "ti-2", label: "Positioning and placement", count: 310, icon: "wrench" },
|
||||
{ id: "ti-3", label: "Request for more trigger types", count: 280, icon: "lightbulb" },
|
||||
{ id: "ti-4", label: "Miscellaneous in-app feedback", count: 190, icon: "message-circle" },
|
||||
],
|
||||
},
|
||||
"l3-1b": {
|
||||
keywordId: "l3-1b",
|
||||
keywordName: "Drag & drop editor",
|
||||
count: 1400,
|
||||
description:
|
||||
"The Formbricks survey builder lets you add and reorder questions with drag and drop, configure question settings, and preview surveys before publishing.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Issues", count: 600, color: "red" },
|
||||
{ id: "t2", label: "Ideas", count: 500, color: "orange" },
|
||||
{ id: "t3", label: "Questions", count: 250, color: "yellow" },
|
||||
{ id: "t4", label: "Other", count: 50, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{ id: "ti-1", label: "Reordering fails with many questions", count: 220, icon: "warning" },
|
||||
{ id: "ti-2", label: "Request for keyboard shortcuts", count: 180, icon: "lightbulb" },
|
||||
{ id: "ti-3", label: "Undo / redo in editor", count: 150, icon: "lightbulb" },
|
||||
{ id: "ti-4", label: "Miscellaneous builder feedback", count: 100, icon: "message-circle" },
|
||||
],
|
||||
},
|
||||
"l3-1c": {
|
||||
keywordId: "l3-1c",
|
||||
keywordName: "Widget initialization",
|
||||
count: 900,
|
||||
description:
|
||||
"How quickly the Formbricks widget loads and becomes ready to display surveys. Includes script load time, SDK init, and first-paint for survey UI.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Issues", count: 550, color: "red" },
|
||||
{ id: "t2", label: "Ideas", count: 250, color: "orange" },
|
||||
{ id: "t3", label: "Questions", count: 100, color: "yellow" },
|
||||
{ id: "t4", label: "Other", count: 0, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{ id: "ti-1", label: "Slow init on mobile networks", count: 280, icon: "warning" },
|
||||
{ id: "ti-2", label: "Blocking main thread", count: 180, icon: "warning" },
|
||||
{ id: "ti-3", label: "Lazy-load SDK suggestion", count: 120, icon: "lightbulb" },
|
||||
],
|
||||
},
|
||||
"l3-1d": {
|
||||
keywordId: "l3-1d",
|
||||
keywordName: "New question types",
|
||||
count: 600,
|
||||
description:
|
||||
"Requests for additional question types in Formbricks surveys (e.g. matrix, ranking, sliders, image choice) to capture different kinds of feedback.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Ideas", count: 450, color: "orange" },
|
||||
{ id: "t2", label: "Questions", count: 100, color: "yellow" },
|
||||
{ id: "t3", label: "Other", count: 50, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{ id: "ti-1", label: "Matrix / grid question", count: 180, icon: "lightbulb" },
|
||||
{ id: "ti-2", label: "Ranking question type", count: 120, icon: "lightbulb" },
|
||||
{ id: "ti-3", label: "Slider and scale variants", count: 90, icon: "lightbulb" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getDetailForL3(keywordId: string): TaxonomyDetail | null {
|
||||
return MOCK_DETAIL_L3[keywordId] ?? null;
|
||||
}
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -71,12 +71,6 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||
|
||||
@@ -9,18 +9,17 @@
|
||||
"source": "en-US",
|
||||
"targets": [
|
||||
"de-DE",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW"
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -165,20 +165,19 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"de-DE",
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"fr-FR",
|
||||
"nl-NL",
|
||||
"zh-Hant-TW",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
@@ -126,12 +126,6 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
|
||||
};
|
||||
|
||||
export const appLanguages = [
|
||||
{
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "German",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "en-US",
|
||||
label: {
|
||||
@@ -139,9 +133,15 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"en-US": "German",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -151,27 +151,9 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "hu-HU",
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Hungarian",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
"en-US": "Chinese (Traditional)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,15 +169,9 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"en-US": "Japanese",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -205,9 +181,27 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
"en-US": "Dutch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -87,30 +87,28 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return de;
|
||||
case "en-US":
|
||||
return enUS;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "fr-FR":
|
||||
return fr;
|
||||
case "hu-HU":
|
||||
return hu;
|
||||
case "ja-JP":
|
||||
return ja;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "pt-BR":
|
||||
return ptBR;
|
||||
case "fr-FR":
|
||||
return fr;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "pt-PT":
|
||||
return pt;
|
||||
case "ro-RO":
|
||||
return ro;
|
||||
case "ru-RU":
|
||||
return ru;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "ja-JP":
|
||||
return ja;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "ru-RU":
|
||||
return ru;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Helle Überlagerung",
|
||||
"limits_reached": "Limits erreicht",
|
||||
"link": "Link",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Du hast dein monatliches MIU-Limit erreicht",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Du hast dein monatliches Antwortlimit erreicht",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Annehmen",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
|
||||
"display_type": "Anzeigetyp",
|
||||
"divide": "Teilen /",
|
||||
"does_not_contain": "Enthält nicht",
|
||||
"does_not_end_with": "Endet nicht mit",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "Enthält nicht alle von",
|
||||
"does_not_include_one_of": "Enthält nicht eines von",
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Block duplizieren",
|
||||
"duplicate_question": "Frage duplizieren",
|
||||
"edit_link": "Bearbeitungslink",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
|
||||
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
|
||||
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
||||
"list": "Liste",
|
||||
"load_segment": "Segment laden",
|
||||
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
||||
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "Frage-ID aktualisiert",
|
||||
"question_used_in_logic_warning_text": "Elemente aus diesem Block werden in einer Logikregel verwendet. Möchten Sie ihn wirklich löschen?",
|
||||
"question_used_in_logic_warning_title": "Logikinkonsistenz",
|
||||
"question_used_in_quota": "Diese Frage wird in der “{quotaName}” Quote verwendet",
|
||||
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
|
||||
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
|
||||
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Validierungsregeln",
|
||||
"validation_rules_description": "Nur Antworten akzeptieren, die die folgenden Kriterien erfüllen",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable “{variableName}” wird in der “{quotaName}” Quote verwendet",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"variable_name_conflicts_with_hidden_field": "Der Variablenname steht im Konflikt mit einer vorhandenen Hidden-Field-ID.",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
|
||||
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -254,7 +254,6 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"learn_more": "Saber más",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Superposición clara",
|
||||
"limits_reached": "Límites alcanzados",
|
||||
"link": "Enlace",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Has alcanzado tu límite mensual de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Has alcanzado tu límite mensual de respuestas de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceptar",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Desactivar la visibilidad del progreso de la encuesta.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar una estimación del tiempo de finalización de la encuesta",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respuestas para la encuesta",
|
||||
"display_type": "Tipo de visualización",
|
||||
"divide": "Dividir /",
|
||||
"does_not_contain": "No contiene",
|
||||
"does_not_end_with": "No termina con",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "No incluye todos los",
|
||||
"does_not_include_one_of": "No incluye uno de",
|
||||
"does_not_start_with": "No comienza con",
|
||||
"dropdown": "Desplegable",
|
||||
"duplicate_block": "Duplicar bloque",
|
||||
"duplicate_question": "Duplicar pregunta",
|
||||
"edit_link": "Editar enlace",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Limita el tamaño máximo de archivo para las subidas.",
|
||||
"limit_upload_file_size_to": "Limitar el tamaño de archivo de subida a",
|
||||
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Cargar segmento",
|
||||
"logic_error_warning": "El cambio causará errores lógicos",
|
||||
"logic_error_warning_text": "Cambiar el tipo de pregunta eliminará las condiciones lógicas de esta pregunta",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "ID de pregunta actualizado",
|
||||
"question_used_in_logic_warning_text": "Los elementos de este bloque se usan en una regla de lógica, ¿estás seguro de que quieres eliminarlo?",
|
||||
"question_used_in_logic_warning_title": "Inconsistencia de lógica",
|
||||
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota “{quotaName}”",
|
||||
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pregunta se está recordando en la pregunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pregunta se está recordando en la Tarjeta Final",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Reglas de validación",
|
||||
"validation_rules_description": "Solo aceptar respuestas que cumplan los siguientes criterios",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala primero de la lógica.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable “{variableName}” se está utilizando en la cuota “{quotaName}”",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" se está utilizando en la cuota \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "El nombre de la variable entra en conflicto con un ID de campo oculto existente.",
|
||||
"variable_name_is_already_taken_please_choose_another": "El nombre de la variable ya está en uso, por favor elige otro.",
|
||||
"variable_name_must_start_with_a_letter": "El nombre de la variable debe comenzar con una letra.",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"learn_more": "En savoir plus",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Claire",
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {projectLimit} espaces de travail.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Vous avez atteint votre limite mensuelle de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Vous avez atteint votre limite de réponses mensuelle de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Accepter",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
|
||||
"display_type": "Type d'affichage",
|
||||
"divide": "Diviser /",
|
||||
"does_not_contain": "Ne contient pas",
|
||||
"does_not_end_with": "Ne se termine pas par",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "n'inclut pas tout",
|
||||
"does_not_include_one_of": "n'inclut pas un de",
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"dropdown": "Menu déroulant",
|
||||
"duplicate_block": "Dupliquer le bloc",
|
||||
"duplicate_question": "Dupliquer la question",
|
||||
"edit_link": "Modifier le lien",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Limiter la taille maximale des fichiers pour les téléversements.",
|
||||
"limit_upload_file_size_to": "Limiter la taille de téléversement des fichiers à",
|
||||
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
||||
"list": "Liste",
|
||||
"load_segment": "Segment de chargement",
|
||||
"logic_error_warning": "Changer causera des erreurs logiques",
|
||||
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "ID de la question mis à jour",
|
||||
"question_used_in_logic_warning_text": "Des éléments de ce bloc sont utilisés dans une règle logique, êtes-vous sûr de vouloir le supprimer ?",
|
||||
"question_used_in_logic_warning_title": "Incohérence de logique",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota “{quotaName}”",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Règles de validation",
|
||||
"validation_rules_description": "Accepter uniquement les réponses qui répondent aux critères suivants",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable “{variableName}” est utilisée dans le quota “{quotaName}”",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "Le nom de la variable est en conflit avec un ID de champ masqué existant.",
|
||||
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
|
||||
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -254,7 +254,6 @@
|
||||
"label": "ラベル",
|
||||
"language": "言語",
|
||||
"learn_more": "詳細を見る",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "明るいオーバーレイ",
|
||||
"limits_reached": "上限に達しました",
|
||||
"link": "リンク",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "月間MIU(月間アクティブユーザー)の上限に達しました",
|
||||
"you_have_reached_your_monthly_response_limit_of": "月間回答数の上限に達しました",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。"
|
||||
},
|
||||
"emails": {
|
||||
"accept": "承認",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
"display_number_of_responses_for_survey": "フォームの回答数を表示",
|
||||
"display_type": "表示タイプ",
|
||||
"divide": "除算 /",
|
||||
"does_not_contain": "を含まない",
|
||||
"does_not_end_with": "で終わらない",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "のすべてを含まない",
|
||||
"does_not_include_one_of": "のいずれも含まない",
|
||||
"does_not_start_with": "で始まらない",
|
||||
"dropdown": "ドロップダウン",
|
||||
"duplicate_block": "ブロックを複製",
|
||||
"duplicate_question": "質問を複製",
|
||||
"edit_link": "編集 リンク",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
|
||||
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
|
||||
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
|
||||
"list": "リスト",
|
||||
"load_segment": "セグメントを読み込み",
|
||||
"logic_error_warning": "変更するとロジックエラーが発生します",
|
||||
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "質問IDを更新しました",
|
||||
"question_used_in_logic_warning_text": "このブロックの要素はロジックルールで使用されていますが、本当に削除しますか?",
|
||||
"question_used_in_logic_warning_title": "ロジックの不整合",
|
||||
"question_used_in_quota": "この質問は“{quotaName}”クォータで使用されています",
|
||||
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
|
||||
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "検証ルール",
|
||||
"validation_rules_description": "次の条件を満たす回答のみを受け付ける",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数“{variableName}”は“{quotaName}”クォータで使用されています",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
"variable_name_conflicts_with_hidden_field": "変数名が既存の非表示フィールドIDと競合しています。",
|
||||
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
|
||||
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Label",
|
||||
"language": "Taal",
|
||||
"learn_more": "Meer informatie",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Lichte overlay",
|
||||
"limits_reached": "Grenzen bereikt",
|
||||
"link": "Link",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {projectLimit} werkruimtes bereikt.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "U heeft uw maandelijkse MIU-limiet van bereikt",
|
||||
"you_have_reached_your_monthly_response_limit_of": "U heeft uw maandelijkse responslimiet bereikt van",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Accepteren",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Schakel de zichtbaarheid van de voortgang van het onderzoek uit.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Geef een schatting weer van de voltooiingstijd voor het onderzoek",
|
||||
"display_number_of_responses_for_survey": "Weergave aantal reacties voor enquête",
|
||||
"display_type": "Weergavetype",
|
||||
"divide": "Verdeling /",
|
||||
"does_not_contain": "Bevat niet",
|
||||
"does_not_end_with": "Eindigt niet met",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "Omvat niet alles",
|
||||
"does_not_include_one_of": "Bevat niet een van",
|
||||
"does_not_start_with": "Begint niet met",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Blok dupliceren",
|
||||
"duplicate_question": "Vraag dupliceren",
|
||||
"edit_link": "Link bewerken",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
|
||||
"limit_upload_file_size_to": "Beperk uploadbestandsgrootte tot",
|
||||
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
|
||||
"list": "Lijst",
|
||||
"load_segment": "Laadsegment",
|
||||
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
|
||||
"logic_error_warning_text": "Als u het vraagtype wijzigt, worden de logische voorwaarden van deze vraag verwijderd",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "Vraag-ID bijgewerkt",
|
||||
"question_used_in_logic_warning_text": "Elementen uit dit blok worden gebruikt in een logische regel, weet je zeker dat je het wilt verwijderen?",
|
||||
"question_used_in_logic_warning_title": "Logica-inconsistentie",
|
||||
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum “{quotaName}”",
|
||||
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum '{quotaName}'",
|
||||
"question_used_in_recall": "Deze vraag wordt teruggehaald in vraag {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Deze vraag wordt teruggeroepen in de Eindkaart",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Validatieregels",
|
||||
"validation_rules_description": "Accepteer alleen antwoorden die voldoen aan de volgende criteria",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele “{variableName}” wordt gebruikt in het quotum “{quotaName}”",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele \"{variableName}\" wordt gebruikt in het \"{quotaName}\" quotum",
|
||||
"variable_name_conflicts_with_hidden_field": "Variabelenaam conflicteert met een bestaande verborgen veld-ID.",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variabelenaam is al in gebruik, kies een andere.",
|
||||
"variable_name_must_start_with_a_letter": "Variabelenaam moet beginnen met een letter.",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Língua",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "link",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu seu limite de {projectLimit} espaços de trabalho.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Você atingiu o seu limite mensal de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Você atingiu o limite mensal de respostas de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceitar",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
|
||||
"display_type": "Tipo de exibição",
|
||||
"divide": "Divida /",
|
||||
"does_not_contain": "não contém",
|
||||
"does_not_end_with": "Não termina com",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"dropdown": "Menu suspenso",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"duplicate_question": "Duplicar pergunta",
|
||||
"edit_link": "Editar link",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo de arquivo para uploads.",
|
||||
"limit_upload_file_size_to": "Limitar tamanho de arquivo de upload para",
|
||||
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
||||
"list": "Lista",
|
||||
"load_segment": "segmento de carga",
|
||||
"logic_error_warning": "Mudar vai causar erros de lógica",
|
||||
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic_warning_text": "Elementos deste bloco são usados em uma regra de lógica, tem certeza de que deseja excluí-lo?",
|
||||
"question_used_in_logic_warning_title": "Inconsistência de lógica",
|
||||
"question_used_in_quota": "Esta pergunta está sendo usada na cota \"{quotaName}\"",
|
||||
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Regras de validação",
|
||||
"validation_rules_description": "Aceitar apenas respostas que atendam aos seguintes critérios",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Atingiu o seu limite de {projectLimit} áreas de trabalho.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Atingiu o seu limite mensal de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Atingiu o seu limite mensal de respostas de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceitar",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
|
||||
"display_type": "Tipo de exibição",
|
||||
"divide": "Dividir /",
|
||||
"does_not_contain": "Não contém",
|
||||
"does_not_end_with": "Não termina com",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"dropdown": "Menu suspenso",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"duplicate_question": "Duplicar pergunta",
|
||||
"edit_link": "Editar link",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo de ficheiro para carregamentos.",
|
||||
"limit_upload_file_size_to": "Limitar o tamanho de ficheiro de carregamento para",
|
||||
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Carregar segmento",
|
||||
"logic_error_warning": "A alteração causará erros de lógica",
|
||||
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Regras de validação",
|
||||
"validation_rules_description": "Aceitar apenas respostas que cumpram os seguintes critérios",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A variável \"{variableName}\" está a ser usada na quota \"{quotaName}\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Etichetă",
|
||||
"language": "Limba",
|
||||
"learn_more": "Află mai multe",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Suprapunere ușoară",
|
||||
"limits_reached": "Limite atinse",
|
||||
"link": "Legătura",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Ați atins limita de {projectLimit} spații de lucru.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Ați atins limita lunară MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Ați atins limita lunară de răspunsuri de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Acceptă",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
"display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj",
|
||||
"display_type": "Tip de afișare",
|
||||
"divide": "Împarte /",
|
||||
"does_not_contain": "Nu conține",
|
||||
"does_not_end_with": "Nu se termină cu",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "Nu include toate",
|
||||
"does_not_include_one_of": "Nu include una dintre",
|
||||
"does_not_start_with": "Nu începe cu",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Duplicați blocul",
|
||||
"duplicate_question": "Duplică întrebarea",
|
||||
"edit_link": "Editare legătură",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Limitați dimensiunea maximă a fișierului pentru încărcări.",
|
||||
"limit_upload_file_size_to": "Limitați dimensiunea fișierului încărcat la",
|
||||
"link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.",
|
||||
"list": "Listă",
|
||||
"load_segment": "Încarcă segment",
|
||||
"logic_error_warning": "Schimbarea va provoca erori de logică",
|
||||
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "ID întrebare actualizat",
|
||||
"question_used_in_logic_warning_text": "Elemente din acest bloc sunt folosite într-o regulă de logică. Sigur doriți să îl ștergeți?",
|
||||
"question_used_in_logic_warning_title": "Inconsistență logică",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota „{quotaName}”",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Reguli de validare",
|
||||
"validation_rules_description": "Acceptă doar răspunsurile care îndeplinesc următoarele criterii",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila „{variableName}” este folosită în cota „{quotaName}”. Vă rugăm să o eliminați mai întâi din cotă",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "Numele variabilei intră în conflict cu un ID de câmp ascuns existent.",
|
||||
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
|
||||
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Метка",
|
||||
"language": "Язык",
|
||||
"learn_more": "Подробнее",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Светлый оверлей",
|
||||
"limits_reached": "Достигнуты лимиты",
|
||||
"link": "Ссылка",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {projectLimit} рабочих пространств.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Вы достигли месячного лимита MIU:",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Вы достигли месячного лимита ответов:",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Принять",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Отключить отображение прогресса опроса.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Показывать примерное время прохождения опроса",
|
||||
"display_number_of_responses_for_survey": "Показывать количество ответов на опрос",
|
||||
"display_type": "Тип отображения",
|
||||
"divide": "Разделить /",
|
||||
"does_not_contain": "Не содержит",
|
||||
"does_not_end_with": "Не заканчивается на",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "Не включает все из",
|
||||
"does_not_include_one_of": "Не включает ни одного из",
|
||||
"does_not_start_with": "Не начинается с",
|
||||
"dropdown": "Выпадающий список",
|
||||
"duplicate_block": "Дублировать блок",
|
||||
"duplicate_question": "Дублировать вопрос",
|
||||
"edit_link": "Редактировать ссылку",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Ограничьте максимальный размер загружаемых файлов.",
|
||||
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
|
||||
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
|
||||
"list": "Список",
|
||||
"load_segment": "Загрузить сегмент",
|
||||
"logic_error_warning": "Изменение приведёт к логическим ошибкам",
|
||||
"logic_error_warning_text": "Изменение типа вопроса удалит логические условия из этого вопроса",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "ID вопроса обновлён",
|
||||
"question_used_in_logic_warning_text": "Элементы из этого блока используются в правиле логики. Вы уверены, что хотите удалить его?",
|
||||
"question_used_in_logic_warning_title": "Несогласованность логики",
|
||||
"question_used_in_quota": "Этот вопрос используется в квоте «{quotaName}»",
|
||||
"question_used_in_quota": "Этот вопрос используется в квоте \"{quotaName}\"",
|
||||
"question_used_in_recall": "Этот вопрос используется в отзыве в вопросе {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Этот вопрос используется в отзыве на финальной карточке",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Правила валидации",
|
||||
"validation_rules_description": "Принимать только ответы, соответствующие следующим критериям",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}». Сначала удалите её из квоты.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}»",
|
||||
"variable_name_conflicts_with_hidden_field": "Имя переменной конфликтует с существующим ID скрытого поля.",
|
||||
"variable_name_is_already_taken_please_choose_another": "Это имя переменной уже занято, выберите другое.",
|
||||
"variable_name_must_start_with_a_letter": "Имя переменной должно начинаться с буквы.",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "Etikett",
|
||||
"language": "Språk",
|
||||
"learn_more": "Läs mer",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Ljust överlägg",
|
||||
"limits_reached": "Gränser nådda",
|
||||
"link": "Länk",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {projectLimit} arbetsytor.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Du har nått din månatliga MIU-gräns på",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Du har nått din månatliga svarsgräns på",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Acceptera",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "Inaktivera synligheten av enkätens framsteg.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Visa en uppskattning av tid för att slutföra enkäten",
|
||||
"display_number_of_responses_for_survey": "Visa antal svar för enkäten",
|
||||
"display_type": "Visningstyp",
|
||||
"divide": "Dividera /",
|
||||
"does_not_contain": "Innehåller inte",
|
||||
"does_not_end_with": "Slutar inte med",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "Inkluderar inte alla av",
|
||||
"does_not_include_one_of": "Inkluderar inte en av",
|
||||
"does_not_start_with": "Börjar inte med",
|
||||
"dropdown": "Rullgardinsmeny",
|
||||
"duplicate_block": "Duplicera block",
|
||||
"duplicate_question": "Duplicera fråga",
|
||||
"edit_link": "Redigera länk",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "Begränsa den maximala filstorleken för uppladdningar.",
|
||||
"limit_upload_file_size_to": "Begränsa uppladdad filstorlek till",
|
||||
"link_survey_description": "Dela en länk till en enkätsida eller bädda in den på en webbsida eller i e-post.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Ladda segment",
|
||||
"logic_error_warning": "Ändring kommer att orsaka logikfel",
|
||||
"logic_error_warning_text": "Att ändra frågetypen kommer att ta bort logikvillkoren från denna fråga",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "Fråge-ID uppdaterat",
|
||||
"question_used_in_logic_warning_text": "Element från det här blocket används i en logikregel. Är du säker på att du vill ta bort det?",
|
||||
"question_used_in_logic_warning_title": "Logikkonflikt",
|
||||
"question_used_in_quota": "Denna fråga används i kvoten “{quotaName}”",
|
||||
"question_used_in_quota": "Denna fråga används i kvoten \"{quotaName}\"",
|
||||
"question_used_in_recall": "Denna fråga återkallas i fråga {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Denna fråga återkallas i avslutningskortet",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "Valideringsregler",
|
||||
"validation_rules_description": "Acceptera endast svar som uppfyller följande kriterier",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabeln “{variableName}” används i kvoten “{quotaName}”",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabel \"{variableName}\" används i kvoten \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "Variabelnamnet krockar med ett befintligt dolt fält-ID.",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variabelnamnet är redan taget, vänligen välj ett annat.",
|
||||
"variable_name_must_start_with_a_letter": "Variabelnamnet måste börja med en bokstav.",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "标签",
|
||||
"language": "语言",
|
||||
"learn_more": "了解 更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "浅色遮罩层",
|
||||
"limits_reached": "限制 达到",
|
||||
"link": "链接",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "您 已经 达到 每月 的 MIU 限制",
|
||||
"you_have_reached_your_monthly_response_limit_of": "您 已经 达到 每月 的 响应 限制",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。"
|
||||
},
|
||||
"emails": {
|
||||
"accept": "接受",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
"display_number_of_responses_for_survey": "显示 调查 响应 数量",
|
||||
"display_type": "显示类型",
|
||||
"divide": "划分 /",
|
||||
"does_not_contain": "不包含",
|
||||
"does_not_end_with": "不 以 结尾",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "不包括所有 ",
|
||||
"does_not_include_one_of": "不包括一 个",
|
||||
"does_not_start_with": "不 以 开头",
|
||||
"dropdown": "下拉菜单",
|
||||
"duplicate_block": "复制区块",
|
||||
"duplicate_question": "复制问题",
|
||||
"edit_link": "编辑 链接",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "限制上传文件的最大大小。",
|
||||
"limit_upload_file_size_to": "将上传文件大小限制为",
|
||||
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
|
||||
"list": "列表",
|
||||
"load_segment": "载入 段落",
|
||||
"logic_error_warning": "更改 将 导致 逻辑 错误",
|
||||
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "问题 ID 更新",
|
||||
"question_used_in_logic_warning_text": "此区块中的元素已被用于逻辑规则,您确定要删除吗?",
|
||||
"question_used_in_logic_warning_title": "逻辑不一致",
|
||||
"question_used_in_quota": "此问题正在被“{quotaName}”配额使用",
|
||||
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
|
||||
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "校验规则",
|
||||
"validation_rules_description": "仅接受符合以下条件的回复",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量“{variableName}”正在被“{quotaName}”配额使用,请先将其从配额中移除",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"variable_name_conflicts_with_hidden_field": "变量名与已有的隐藏字段 ID 冲突。",
|
||||
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
|
||||
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
"label": "標籤",
|
||||
"language": "語言",
|
||||
"learn_more": "瞭解更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "淺色覆蓋",
|
||||
"limits_reached": "已達上限",
|
||||
"link": "連結",
|
||||
@@ -461,8 +460,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "您已達到每月 MIU 上限:",
|
||||
"you_have_reached_your_monthly_response_limit_of": "您已達到每月回應上限:",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。"
|
||||
},
|
||||
"emails": {
|
||||
"accept": "接受",
|
||||
@@ -1273,7 +1271,6 @@
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
"display_number_of_responses_for_survey": "顯示問卷的回應數",
|
||||
"display_type": "顯示類型",
|
||||
"divide": "除 /",
|
||||
"does_not_contain": "不包含",
|
||||
"does_not_end_with": "不以...結尾",
|
||||
@@ -1281,7 +1278,6 @@
|
||||
"does_not_include_all_of": "不包含全部",
|
||||
"does_not_include_one_of": "不包含其中之一",
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"dropdown": "下拉選單",
|
||||
"duplicate_block": "複製區塊",
|
||||
"duplicate_question": "複製問題",
|
||||
"edit_link": "編輯 連結",
|
||||
@@ -1414,7 +1410,6 @@
|
||||
"limit_the_maximum_file_size": "限制上傳檔案的最大大小。",
|
||||
"limit_upload_file_size_to": "將上傳檔案大小限制為",
|
||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||
"list": "清單",
|
||||
"load_segment": "載入區隔",
|
||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
@@ -1474,7 +1469,7 @@
|
||||
"question_id_updated": "問題 ID 已更新",
|
||||
"question_used_in_logic_warning_text": "此區塊中的元素已用於邏輯規則,確定要刪除嗎?",
|
||||
"question_used_in_logic_warning_title": "邏輯不一致",
|
||||
"question_used_in_quota": "此問題正被使用於「{quotaName}」配額中",
|
||||
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
|
||||
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
|
||||
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
|
||||
"quotas": {
|
||||
@@ -1648,7 +1643,7 @@
|
||||
"validation_rules": "驗證規則",
|
||||
"validation_rules_description": "僅接受符合下列條件的回應",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數「{variableName}」正被使用於「{quotaName}」配額中",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
"variable_name_conflicts_with_hidden_field": "變數名稱與現有的隱藏欄位 ID 衝突。",
|
||||
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
|
||||
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
|
||||
|
||||
@@ -157,7 +157,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
test("should return cached license from FETCH_LICENSE_CACHE_KEY if available and valid", async () => {
|
||||
@@ -234,7 +233,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: previousTime,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,7 +309,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,7 +356,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,7 +389,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
});
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
@@ -419,7 +414,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,17 +38,6 @@ const CONFIG = {
|
||||
// Types
|
||||
type FallbackLevel = "live" | "cached" | "grace" | "default";
|
||||
|
||||
type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "no-license";
|
||||
|
||||
type TEnterpriseLicenseResult = {
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
status: TEnterpriseLicenseStatusReturn;
|
||||
};
|
||||
|
||||
type TPreviousResult = {
|
||||
active: boolean;
|
||||
lastChecked: Date;
|
||||
@@ -101,7 +90,7 @@ class LicenseApiError extends LicenseError {
|
||||
|
||||
// Cache keys using enterprise-grade hierarchical patterns
|
||||
const getCacheIdentifier = () => {
|
||||
if (globalThis.window !== undefined) {
|
||||
if (typeof window !== "undefined") {
|
||||
return "browser"; // Browser environment
|
||||
}
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) {
|
||||
@@ -153,50 +142,36 @@ const validateConfig = () => {
|
||||
};
|
||||
|
||||
// Cache functions with async pattern
|
||||
let getPreviousResultPromise: Promise<TPreviousResult> | null = null;
|
||||
|
||||
const getPreviousResult = async (): Promise<TPreviousResult> => {
|
||||
if (getPreviousResultPromise) return getPreviousResultPromise;
|
||||
|
||||
getPreviousResultPromise = (async () => {
|
||||
if (globalThis.window !== undefined) {
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
|
||||
if (result.ok && result.data) {
|
||||
return {
|
||||
...result.data,
|
||||
lastChecked: new Date(result.data.lastChecked),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get previous result from cache");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
getPreviousResultPromise
|
||||
.finally(() => {
|
||||
getPreviousResultPromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
|
||||
if (result.ok && result.data) {
|
||||
return {
|
||||
...result.data,
|
||||
lastChecked: new Date(result.data.lastChecked),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get previous result from cache");
|
||||
}
|
||||
|
||||
return getPreviousResultPromise;
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
};
|
||||
};
|
||||
|
||||
const setPreviousResult = async (previousResult: TPreviousResult) => {
|
||||
if (globalThis.window !== undefined) return;
|
||||
if (typeof window !== "undefined") return;
|
||||
|
||||
try {
|
||||
const result = await cache.set(
|
||||
@@ -246,21 +221,12 @@ const validateLicenseDetails = (data: unknown): TEnterpriseLicenseDetails => {
|
||||
};
|
||||
|
||||
// Fallback functions
|
||||
let memoryCache: {
|
||||
data: TEnterpriseLicenseResult;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
|
||||
const MEMORY_CACHE_TTL_MS = 60 * 1000; // 1 minute memory cache to avoid stampedes and reduce load when Redis is slow
|
||||
|
||||
let getEnterpriseLicensePromise: Promise<TEnterpriseLicenseResult> | null = null;
|
||||
|
||||
const getFallbackLevel = (
|
||||
liveLicense: TEnterpriseLicenseDetails | null,
|
||||
previousResult: TPreviousResult,
|
||||
currentTime: Date
|
||||
): FallbackLevel => {
|
||||
if (liveLicense?.status === "active") return "live";
|
||||
if (liveLicense) return "live";
|
||||
if (previousResult.active) {
|
||||
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
||||
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
|
||||
@@ -268,7 +234,7 @@ const getFallbackLevel = (
|
||||
return "default";
|
||||
};
|
||||
|
||||
const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicenseResult> => {
|
||||
const handleInitialFailure = async (currentTime: Date) => {
|
||||
const initialFailResult: TPreviousResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
@@ -281,13 +247,10 @@ const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicen
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
};
|
||||
};
|
||||
|
||||
// API functions
|
||||
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
|
||||
|
||||
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
|
||||
@@ -303,7 +266,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
// first millisecond of next year => current year is fully included
|
||||
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
|
||||
|
||||
const startTime = Date.now();
|
||||
const [instanceId, responseCount] = await Promise.all([
|
||||
// Skip instance ID during E2E tests to avoid license key conflicts
|
||||
// as the instance ID changes with each test run
|
||||
@@ -317,11 +279,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (duration > 1000) {
|
||||
logger.warn({ duration, responseCount }, "Slow license check prerequisite data fetching (DB count)");
|
||||
}
|
||||
|
||||
// No organization exists, cannot perform license check
|
||||
// (skip this check during E2E tests as we intentionally use null)
|
||||
@@ -354,19 +311,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
|
||||
if (res.ok) {
|
||||
const responseJson = (await res.json()) as { data: unknown };
|
||||
const licenseDetails = validateLicenseDetails(responseJson.data);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
status: licenseDetails.status,
|
||||
instanceId: instanceId ?? "not-set",
|
||||
responseCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"License check API response received"
|
||||
);
|
||||
|
||||
return licenseDetails;
|
||||
return validateLicenseDetails(responseJson.data);
|
||||
}
|
||||
|
||||
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
||||
@@ -397,41 +342,23 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fetchLicensePromise) {
|
||||
return fetchLicensePromise;
|
||||
}
|
||||
|
||||
fetchLicensePromise = (async () => {
|
||||
return await cache.withCache(
|
||||
async () => {
|
||||
return await fetchLicenseFromServerInternal();
|
||||
},
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
})();
|
||||
|
||||
fetchLicensePromise
|
||||
.finally(() => {
|
||||
fetchLicensePromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return fetchLicensePromise;
|
||||
return await cache.withCache(
|
||||
async () => {
|
||||
return await fetchLicenseFromServerInternal();
|
||||
},
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
};
|
||||
|
||||
export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLicenseResult> => {
|
||||
if (
|
||||
process.env.NODE_ENV !== "test" &&
|
||||
memoryCache &&
|
||||
Date.now() - memoryCache.timestamp < MEMORY_CACHE_TTL_MS
|
||||
) {
|
||||
return memoryCache.data;
|
||||
}
|
||||
|
||||
if (getEnterpriseLicensePromise) return getEnterpriseLicensePromise;
|
||||
|
||||
getEnterpriseLicensePromise = (async () => {
|
||||
export const getEnterpriseLicense = reactCache(
|
||||
async (): Promise<{
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
}> => {
|
||||
validateConfig();
|
||||
|
||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
@@ -441,11 +368,12 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = new Date();
|
||||
const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]);
|
||||
const liveLicenseDetails = await fetchLicense();
|
||||
const previousResult = await getPreviousResult();
|
||||
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
|
||||
|
||||
trackFallbackUsage(fallbackLevel);
|
||||
@@ -453,84 +381,41 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
|
||||
let currentLicenseState: TPreviousResult | undefined;
|
||||
|
||||
switch (fallbackLevel) {
|
||||
case "live": {
|
||||
case "live":
|
||||
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
|
||||
currentLicenseState = {
|
||||
active: liveLicenseDetails.status === "active",
|
||||
features: liveLicenseDetails.features,
|
||||
lastChecked: currentTime,
|
||||
};
|
||||
|
||||
// Only update previous result if it's actually different or if it's old (1 hour)
|
||||
// This prevents hammering Redis on every request when the license is active
|
||||
if (
|
||||
!previousResult.active ||
|
||||
previousResult.active !== currentLicenseState.active ||
|
||||
currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000
|
||||
) {
|
||||
await setPreviousResult(currentLicenseState);
|
||||
}
|
||||
|
||||
const liveResult: TEnterpriseLicenseResult = {
|
||||
await setPreviousResult(currentLicenseState);
|
||||
return {
|
||||
active: currentLicenseState.active,
|
||||
features: currentLicenseState.features,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
status: liveLicenseDetails.status,
|
||||
};
|
||||
memoryCache = { data: liveResult, timestamp: Date.now() };
|
||||
return liveResult;
|
||||
}
|
||||
|
||||
case "grace": {
|
||||
case "grace":
|
||||
if (!validateFallback(previousResult)) {
|
||||
return await handleInitialFailure(currentTime);
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
const graceResult: TEnterpriseLicenseResult = {
|
||||
return {
|
||||
active: previousResult.active,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
|
||||
};
|
||||
memoryCache = { data: graceResult, timestamp: Date.now() };
|
||||
return graceResult;
|
||||
}
|
||||
|
||||
case "default": {
|
||||
if (liveLicenseDetails?.status === "expired") {
|
||||
const expiredResult: TEnterpriseLicenseResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "expired" as const,
|
||||
};
|
||||
memoryCache = { data: expiredResult, timestamp: Date.now() };
|
||||
return expiredResult;
|
||||
}
|
||||
const failResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: failResult, timestamp: Date.now() };
|
||||
return failResult;
|
||||
}
|
||||
case "default":
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
|
||||
const finalFailResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: finalFailResult, timestamp: Date.now() };
|
||||
return finalFailResult;
|
||||
})();
|
||||
|
||||
getEnterpriseLicensePromise
|
||||
.finally(() => {
|
||||
getEnterpriseLicensePromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return getEnterpriseLicensePromise;
|
||||
});
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
);
|
||||
|
||||
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,6 @@ type TEnterpriseLicense = {
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
status: "active" | "expired" | "unreachable" | "no-license";
|
||||
};
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -10,7 +10,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TMultipleChoiceOptionDisplayType, TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
@@ -21,7 +21,6 @@ import { ValidationRulesEditor } from "@/modules/survey/editor/components/valida
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||
|
||||
interface MultipleChoiceElementFormProps {
|
||||
@@ -76,11 +75,6 @@ export const MultipleChoiceElementForm = ({
|
||||
},
|
||||
};
|
||||
|
||||
const multipleChoiceOptionDisplayTypeOptions = [
|
||||
{ value: "list", label: t("environments.surveys.edit.list") },
|
||||
{ value: "dropdown", label: t("environments.surveys.edit.dropdown") },
|
||||
];
|
||||
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
|
||||
let newChoices: any[] = [];
|
||||
if (element.choices) {
|
||||
@@ -388,20 +382,6 @@ export const MultipleChoiceElementForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label>{t("environments.surveys.edit.display_type")}</Label>
|
||||
<div className="mt-2">
|
||||
<OptionsSwitch
|
||||
options={multipleChoiceOptionDisplayTypeOptions}
|
||||
currentOption={element.displayType ?? "list"}
|
||||
handleOptionChange={(value: TMultipleChoiceOptionDisplayType) =>
|
||||
updateElement(elementIdx, { displayType: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BulkEditOptionsModal
|
||||
isOpen={isBulkEditOpen}
|
||||
onClose={() => setIsBulkEditOpen(false)}
|
||||
|
||||
@@ -68,7 +68,6 @@ export const getWebAppLocale = (languageCode: string, survey: TSurvey): string =
|
||||
"pt-BR": "pt-BR",
|
||||
"pt-PT": "pt-PT",
|
||||
fr: "fr-FR",
|
||||
hu: "hu-HU",
|
||||
nl: "nl-NL",
|
||||
zh: "zh-Hans-CN", // Default to Simplified Chinese
|
||||
"zh-Hans": "zh-Hans-CN",
|
||||
|
||||
146
apps/web/modules/ui/components/icons/angry-bird-rage-2-icon.tsx
Normal file
146
apps/web/modules/ui/components/icons/angry-bird-rage-2-icon.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
export const AngryBirdRage2Icon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width="79" height="75" viewBox="0 0 79 75" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M70.1683 50C70.1683 66.4 56.4144 73.4375 39.4459 73.4375C22.4774 73.4375 8.72339 66.4 8.72339 50C8.72339 33.6 22.4806 10.9375 39.4459 10.9375C56.4111 10.9375 70.1683 33.6062 70.1683 50Z"
|
||||
fill="#00E6CA"
|
||||
/>
|
||||
<path
|
||||
d="M39.4457 23.4375C54.2216 23.4375 66.5591 40.625 69.502 55.8906C69.9564 53.9582 70.1799 51.9817 70.1682 50C70.1682 33.6063 56.4142 10.9375 39.4457 10.9375C22.4772 10.9375 8.72323 33.6063 8.72323 50C8.7131 51.9816 8.9366 53.9579 9.38942 55.8906C12.3355 40.625 24.673 23.4375 39.4457 23.4375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M70.1683 50C70.1683 66.4 56.4144 73.4375 39.4459 73.4375C22.4774 73.4375 8.72339 66.4 8.72339 50C8.72339 33.6 22.4806 10.9375 39.4459 10.9375C56.4111 10.9375 70.1683 33.6062 70.1683 50Z"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M29.5239 56L38.2168 65.8031C38.3686 65.9747 38.557 66.1124 38.7692 66.2068C38.9813 66.3012 39.2121 66.3501 39.4457 66.3501C39.6792 66.3501 39.91 66.3012 40.1222 66.2068C40.3343 66.1124 40.5228 65.9747 40.6746 65.8031L49.3674 56"
|
||||
fill="#00E6CA"
|
||||
/>
|
||||
<path
|
||||
d="M29.5239 56L38.2168 65.8031C38.3686 65.9747 38.557 66.1124 38.7692 66.2068C38.9813 66.3012 39.2121 66.3501 39.4457 66.3501C39.6792 66.3501 39.91 66.3012 40.1222 66.2068C40.3343 66.1124 40.5228 65.9747 40.6746 65.8031L49.3674 56"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.9826 28.2407C29.6749 27.9941 28.3204 28.1438 27.104 28.6693C25.8877 29.1948 24.8691 30.0705 24.1872 31.1766C23.5054 32.2827 23.1937 33.5653 23.2948 34.849C23.3958 36.1328 23.9047 37.3551 24.7518 38.3488C25.5989 39.3425 26.7429 40.0592 28.0275 40.4009C29.312 40.7426 30.6745 40.6926 31.9285 40.2577C33.1826 39.8229 34.2671 39.0244 35.0337 37.9715C35.8004 36.9186 36.2119 35.6625 36.2119 34.3751C36.213 33.3778 35.9657 32.3949 35.4907 31.5094"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M30.9826 28.2407C29.6749 27.9941 28.3204 28.1438 27.104 28.6693C25.8877 29.1948 24.8691 30.0705 24.1872 31.1766C23.5054 32.2827 23.1937 33.5653 23.2948 34.849C23.3958 36.1328 23.9047 37.3551 24.7518 38.3488C25.5989 39.3425 26.7429 40.0592 28.0275 40.4009C29.312 40.7426 30.6745 40.6926 31.9285 40.2577C33.1826 39.8229 34.2671 39.0244 35.0337 37.9715C35.8004 36.9186 36.2119 35.6625 36.2119 34.3751C36.213 33.3778 35.9657 32.3949 35.4907 31.5094"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.909 28.2407C49.2167 27.9941 50.5712 28.1438 51.7875 28.6693C53.0039 29.1948 54.0225 30.0705 54.7044 31.1766C55.3862 32.2827 55.6979 33.5653 55.5968 34.849C55.4958 36.1328 54.9869 37.3551 54.1398 38.3488C53.2927 39.3425 52.1487 40.0592 50.8641 40.4009C49.5796 40.7426 48.2171 40.6926 46.9631 40.2577C45.709 39.8229 44.6245 39.0244 43.8579 37.9715C43.0912 36.9186 42.6797 35.6625 42.6797 34.3751C42.6786 33.3778 42.9259 32.3949 43.4009 31.5094"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M47.909 28.2407C49.2167 27.9941 50.5712 28.1438 51.7875 28.6693C53.0039 29.1948 54.0225 30.0705 54.7044 31.1766C55.3862 32.2827 55.6979 33.5653 55.5968 34.849C55.4958 36.1328 54.9869 37.3551 54.1398 38.3488C53.2927 39.3425 52.1487 40.0592 50.8641 40.4009C49.5796 40.7426 48.2171 40.6926 46.9631 40.2577C45.709 39.8229 44.6245 39.0244 43.8579 37.9715C43.0912 36.9186 42.6797 35.6625 42.6797 34.3751C42.6786 33.3778 42.9259 32.3949 43.4009 31.5094"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.5722 54.2156C25.3243 54.1002 25.111 53.9255 24.9525 53.7082C24.794 53.4908 24.6955 53.238 24.6663 52.9736C24.6372 52.7092 24.6783 52.4419 24.7859 52.1972C24.8935 51.9525 25.0639 51.7383 25.2811 51.575C28.819 48.9125 36.1083 43.75 39.4458 43.75C42.7832 43.75 50.0725 48.9125 53.6105 51.5625C53.8276 51.7258 53.998 51.94 54.1056 52.1847C54.2132 52.4294 54.2544 52.6967 54.2252 52.9611C54.1961 53.2255 54.0976 53.4783 53.9391 53.6957C53.7806 53.913 53.5673 54.0877 53.3194 54.2031C48.9386 56.4845 44.2772 58.2223 39.4458 59.375C34.615 58.2262 29.9536 56.4927 25.5722 54.2156Z"
|
||||
fill="#00E6CA"
|
||||
/>
|
||||
<path
|
||||
d="M39.4458 59.375C34.615 58.2262 29.9536 56.4927 25.5722 54.2156C25.3243 54.1002 25.111 53.9255 24.9525 53.7082C24.794 53.4908 24.6955 53.238 24.6663 52.9736C24.6372 52.7092 24.6783 52.4419 24.7859 52.1972C24.8935 51.9525 25.0639 51.7383 25.2811 51.575C28.819 48.9125 36.1083 43.75 39.4458 43.75V59.375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M25.5722 54.2156C25.3243 54.1002 25.111 53.9255 24.9525 53.7082C24.794 53.4908 24.6955 53.238 24.6663 52.9736C24.6372 52.7092 24.6783 52.4419 24.7859 52.1972C24.8935 51.9525 25.0639 51.7383 25.2811 51.575C28.819 48.9125 36.1083 43.75 39.4458 43.75C42.7832 43.75 50.0725 48.9125 53.6105 51.5625C53.8276 51.7258 53.998 51.94 54.1056 52.1847C54.2132 52.4294 54.2544 52.6967 54.2252 52.9611C54.1961 53.2255 54.0976 53.4783 53.9391 53.6957C53.7806 53.913 53.5673 54.0877 53.3194 54.2031C48.9386 56.4845 44.2772 58.2223 39.4458 59.375C34.615 58.2262 29.9536 56.4927 25.5722 54.2156V54.2156Z"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.9275 12.8469L20.042 7.8125"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M33.8349 11.7687L26.51 1.5625"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M26.51 25L39.4458 34.375L52.3816 25"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M29.7439 33.5938C29.9583 33.5938 30.164 33.6761 30.3156 33.8226C30.4672 33.9691 30.5524 34.1678 30.5524 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M28.9355 34.375C28.9355 34.1678 29.0207 33.9691 29.1723 33.8226C29.324 33.6761 29.5296 33.5938 29.744 33.5938"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M29.744 35.1562C29.5296 35.1562 29.324 35.0739 29.1723 34.9274C29.0207 34.7809 28.9355 34.5822 28.9355 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.5524 34.375C30.5524 34.5822 30.4672 34.7809 30.3156 34.9274C30.164 35.0739 29.9583 35.1562 29.7439 35.1562"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.1476 33.5938C48.9332 33.5938 48.7275 33.6761 48.5759 33.8226C48.4243 33.9691 48.3391 34.1678 48.3391 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.9562 34.375C49.9562 34.1678 49.871 33.9691 49.7194 33.8226C49.5678 33.6761 49.3621 33.5938 49.1477 33.5938"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.1477 35.1562C49.3621 35.1562 49.5678 35.0739 49.7194 34.9274C49.871 34.7809 49.9562 34.5822 49.9562 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M48.3391 34.375C48.3391 34.5822 48.4243 34.7809 48.5759 34.9274C48.7275 35.0739 48.9332 35.1562 49.1476 35.1562"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
142
apps/web/modules/ui/components/icons/angry-bird-rage-icon.tsx
Normal file
142
apps/web/modules/ui/components/icons/angry-bird-rage-icon.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
export const AngryBirdRageIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"video-game-angry-birds"}</title>
|
||||
<path
|
||||
d="M21.5,16c0,5.248-4.253,7.5-9.5,7.5S2.5,21.248,2.5,16,6.754,3.5,12,3.5,21.5,10.754,21.5,16Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M12,7.5c4.569,0,8.384,5.5,9.294,10.385A8.293,8.293,0,0,0,21.5,16c0-5.246-4.253-12.5-9.5-12.5S2.5,10.754,2.5,16a8.35,8.35,0,0,0,.206,1.885C3.617,13,7.432,7.5,12,7.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M21.5,16c0,5.248-4.253,7.5-9.5,7.5S2.5,21.248,2.5,16,6.754,3.5,12,3.5,21.5,10.754,21.5,16Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M8.932,17.92l2.688,3.137a.5.5,0,0,0,.76,0l2.688-3.137"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.383,9.037A2,2,0,1,0,11,11a1.988,1.988,0,0,0-.223-.917"
|
||||
fill="#f8fafc"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.617,9.037A2,2,0,1,1,13,11a1.988,1.988,0,0,1,.223-.917"
|
||||
fill="#f8fafc"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.71,17.349a.5.5,0,0,1-.09-.845C8.714,15.652,10.968,14,12,14s3.286,1.652,4.38,2.5a.5.5,0,0,1-.09.845A18.278,18.278,0,0,1,12,19,18.278,18.278,0,0,1,7.71,17.349Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M12,19a18.278,18.278,0,0,1-4.29-1.651.5.5,0,0,1-.09-.845C8.714,15.652,10.968,14,12,14Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M7.71,17.349a.5.5,0,0,1-.09-.845C8.714,15.652,10.968,14,12,14s3.286,1.652,4.38,2.5a.5.5,0,0,1-.09.845A18.278,18.278,0,0,1,12,19,18.278,18.278,0,0,1,7.71,17.349Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={9.366}
|
||||
y1={4.111}
|
||||
x2={6}
|
||||
y2={2.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={10.265}
|
||||
y1={3.766}
|
||||
x2={8}
|
||||
y2={0.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points="8 8 12 11 16 8"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M9,10.75a.25.25,0,0,1,.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M8.75,11A.25.25,0,0,1,9,10.75"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M9,11.25A.25.25,0,0,1,8.75,11"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M9.25,11a.25.25,0,0,1-.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M15,10.75a.25.25,0,0,0-.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M15.25,11a.25.25,0,0,0-.25-.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M15,11.25a.25.25,0,0,0,.25-.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M14.75,11a.25.25,0,0,0,.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
114
apps/web/modules/ui/components/icons/app-pie-chart-icon.tsx
Normal file
114
apps/web/modules/ui/components/icons/app-pie-chart-icon.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
export const AppPieChartIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path d="M23.5,7V20a2,2,0,0,1-2,2H2.5a2,2,0,0,1-2-2V7Z" fill="#00e6ca" />
|
||||
<path d="M2.5,22h2l15-15H.5V20A2,2,0,0,0,2.5,22Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M13,14.5a4.993,4.993,0,0,0-2.178-4.128L8,14.5l3.205,3.837A4.988,4.988,0,0,0,13,14.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M10.822,10.372A5,5,0,0,0,3,14.5H8Z" fill="#c4f0eb" />
|
||||
<path d="M3,14.5a5,5,0,0,0,8.205,3.837L8,14.5Z" fill="#00e6ca" />
|
||||
<path d="M23.5,6.5H.5v-3a2,2,0,0,1,2-2h19a2,2,0,0,1,2,2Z" fill="#f8fafc" />
|
||||
<rect
|
||||
x={0.5}
|
||||
y={1.504}
|
||||
width={23}
|
||||
height={21}
|
||||
rx={2}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={0.5}
|
||||
y1={6.504}
|
||||
x2={23.5}
|
||||
y2={6.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4,3.754A.25.25,0,1,1,3.75,4,.25.25,0,0,1,4,3.754"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7,3.754A.25.25,0,1,1,6.75,4,.25.25,0,0,1,7,3.754"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10,3.754A.25.25,0,1,1,9.75,4a.25.25,0,0,1,.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx={8}
|
||||
cy={14.504}
|
||||
r={5}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="10.821 10.376 8 14.504 11.205 18.341"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={8}
|
||||
y1={14.504}
|
||||
x2={3}
|
||||
y2={14.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={15}
|
||||
y1={10.504}
|
||||
x2={21}
|
||||
y2={10.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={15}
|
||||
y1={13.504}
|
||||
x2={21}
|
||||
y2={13.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={15}
|
||||
y1={16.504}
|
||||
x2={21}
|
||||
y2={16.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
53
apps/web/modules/ui/components/icons/archive-icon.tsx
Normal file
53
apps/web/modules/ui/components/icons/archive-icon.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
export const ArchiveIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M23.0625 3.9375H3.9375C3.31618 3.9375 2.8125 4.44118 2.8125 5.0625V16.0695C2.8125 16.6908 3.31618 17.1945 3.9375 17.1945H23.0625C23.6838 17.1945 24.1875 16.6908 24.1875 16.0695V5.0625C24.1875 4.44118 23.6838 3.9375 23.0625 3.9375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M19.6875 14.0625C19.3891 14.0625 19.103 14.181 18.892 14.392C18.681 14.603 18.5625 14.8891 18.5625 15.1875C18.5625 15.4859 18.444 15.772 18.233 15.983C18.022 16.194 17.7359 16.3125 17.4375 16.3125H9.5625C9.26413 16.3125 8.97798 16.194 8.767 15.983C8.55603 15.772 8.4375 15.4859 8.4375 15.1875C8.4375 14.8891 8.31897 14.603 8.108 14.392C7.89702 14.181 7.61087 14.0625 7.3125 14.0625H1.6875C1.38913 14.0625 1.10298 14.181 0.892005 14.392C0.681026 14.603 0.5625 14.8891 0.5625 15.1875V21.9375C0.5625 22.2359 0.681026 22.522 0.892005 22.733C1.10298 22.944 1.38913 23.0625 1.6875 23.0625H25.3125C25.6109 23.0625 25.897 22.944 26.108 22.733C26.319 22.522 26.4375 22.2359 26.4375 21.9375V15.1875C26.4375 14.8891 26.319 14.603 26.108 14.392C25.897 14.181 25.6109 14.0625 25.3125 14.0625H19.6875Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M25.3125 20.0869H1.6875C1.38913 20.0869 1.10298 19.9684 0.892005 19.7574C0.681026 19.5464 0.5625 19.2603 0.5625 18.9619V21.9375C0.5625 22.2359 0.681026 22.5221 0.892005 22.733C1.10298 22.944 1.38913 23.0625 1.6875 23.0625H25.3125C25.6109 23.0625 25.897 22.944 26.108 22.733C26.319 22.5221 26.4375 22.2359 26.4375 21.9375V18.9619C26.4375 19.2603 26.319 19.5464 26.108 19.7574C25.897 19.9684 25.6109 20.0869 25.3125 20.0869Z"
|
||||
fill="#00C4B8"
|
||||
/>
|
||||
<path
|
||||
d="M10.6875 14.0625H16.3125"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.8125 11.8125C2.8125 11.5141 2.93103 11.228 3.142 11.017C3.35298 10.806 3.63913 10.6875 3.9375 10.6875H23.0625C23.3609 10.6875 23.647 10.806 23.858 11.017C24.069 11.228 24.1875 11.5141 24.1875 11.8125"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.8125 8.4375C2.8125 8.13913 2.93103 7.85298 3.142 7.64201C3.35298 7.43103 3.63913 7.3125 3.9375 7.3125H23.0625C23.3609 7.3125 23.647 7.43103 23.858 7.64201C24.069 7.85298 24.1875 8.13913 24.1875 8.4375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.8125 5.0625C2.8125 4.76413 2.93103 4.47798 3.142 4.267C3.35298 4.05603 3.63913 3.9375 3.9375 3.9375H23.0625C23.3609 3.9375 23.647 4.05603 23.858 4.267C24.069 4.47798 24.1875 4.76413 24.1875 5.0625"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.6875 14.0625C19.3891 14.0625 19.103 14.181 18.892 14.392C18.681 14.603 18.5625 14.8891 18.5625 15.1875C18.5625 15.4859 18.444 15.772 18.233 15.983C18.022 16.194 17.7359 16.3125 17.4375 16.3125H9.5625C9.26413 16.3125 8.97798 16.194 8.76701 15.983C8.55603 15.772 8.4375 15.4859 8.4375 15.1875C8.4375 14.8891 8.31897 14.603 8.10799 14.392C7.89702 14.181 7.61087 14.0625 7.3125 14.0625H1.6875C1.38913 14.0625 1.10298 14.181 0.892005 14.392C0.681026 14.603 0.5625 14.8891 0.5625 15.1875V21.9375C0.5625 22.2359 0.681026 22.522 0.892005 22.733C1.10298 22.944 1.38913 23.0625 1.6875 23.0625H25.3125C25.6109 23.0625 25.897 22.944 26.108 22.733C26.319 22.522 26.4375 22.2359 26.4375 21.9375V15.1875C26.4375 14.8891 26.319 14.603 26.108 14.392C25.897 14.181 25.6109 14.0625 25.3125 14.0625H19.6875Z"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export const ArrowRightCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<circle cx={12} cy={12} r={9.5} fill="#00e6ca" />
|
||||
<path
|
||||
d="M1.414,16.5a11.5,11.5,0,1,0,0-9"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="12.5 16 16.5 12 12.5 8"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={16.5}
|
||||
y1={12}
|
||||
x2={0.5}
|
||||
y2={12}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
22
apps/web/modules/ui/components/icons/arrow-up-right-icon.tsx
Normal file
22
apps/web/modules/ui/components/icons/arrow-up-right-icon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export const ArrowUpRightIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<circle cx={12} cy={12} r={10.5} fill="#c4f0eb" />
|
||||
<path
|
||||
d="M1.25,18.25,8.586,10a1.042,1.042,0,0,1,1.432-.107l4.464,3.72a1.038,1.038,0,0,0,1.43-.11L22.75,5.75"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="15.812 5.75 22.75 5.75 22.75 11.729"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
24
apps/web/modules/ui/components/icons/back-icon.tsx
Normal file
24
apps/web/modules/ui/components/icons/back-icon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export const BackIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width={32} height={32} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M16.0001 29.3337C23.3639 29.3337 29.3334 23.3641 29.3334 16.0003C29.3334 8.63653 23.3639 2.66699 16.0001 2.66699C8.63628 2.66699 2.66675 8.63653 2.66675 16.0003C2.66675 23.3641 8.63628 29.3337 16.0001 29.3337Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M0.666748 13.9971H31.3334V23.3304"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.33341 20.6644L0.666748 13.9977L7.33341 7.33105"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
50
apps/web/modules/ui/components/icons/baseball-icon.tsx
Normal file
50
apps/web/modules/ui/components/icons/baseball-icon.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
export const BaseballIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"baseball-bat-ball"}</title>
|
||||
<circle
|
||||
cx={18.25}
|
||||
cy={17.87}
|
||||
r={2.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#f8fafc"
|
||||
/>
|
||||
<path
|
||||
d="M4.743,21.423,8.6,17.562,21.891,6.549a3.19,3.19,0,1,0-4.5-4.461L6.511,15.409,2.62,19.3Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M22.174,1.826,22.153,1.8a3.19,3.19,0,0,0-4.763.284L6.511,15.409,2.62,19.3,3.66,20.34Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M1.206,19.3a1,1,0,0,1,1.414,0l2.123,2.122a1,1,0,1,1-1.415,1.414L1.206,20.715A1,1,0,0,1,1.206,19.3Z"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect
|
||||
x={5.084}
|
||||
y={16.209}
|
||||
width={2.5}
|
||||
height={3.001}
|
||||
transform="translate(-10.667 9.666) rotate(-45.001)"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.743,21.423,8.6,17.562,21.891,6.549a3.19,3.19,0,1,0-4.5-4.461L6.511,15.409,2.62,19.3Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
26
apps/web/modules/ui/components/icons/bell-icon.tsx
Normal file
26
apps/web/modules/ui/components/icons/bell-icon.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export const BellIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M15,20.5a3,3,0,1,1-6,0Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M20.5,17.5V11a8.5,8.5,0,0,0-5.541-7.959,3,3,0,0,0-5.922,0A8.493,8.493,0,0,0,3.5,11v6.5a3,3,0,0,1-3,3h23A3,3,0,0,1,20.5,17.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M12,.5A3,3,0,0,0,9.037,3.044,8.5,8.5,0,0,0,3.5,11v6.5a3,3,0,0,1-3,3H12Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M20.5,17.5V11a8.5,8.5,0,0,0-5.541-7.959,3,3,0,0,0-5.922,0A8.493,8.493,0,0,0,3.5,11v6.5a3,3,0,0,1-3,3h23A3,3,0,0,1,20.5,17.5Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
89
apps/web/modules/ui/components/icons/brain-icon.tsx
Normal file
89
apps/web/modules/ui/components/icons/brain-icon.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
export const BrainIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"study-brain"}</title>
|
||||
<path
|
||||
d="M20.72,14.98A3.735,3.735,0,0,1,17.75,21c-.11,0-.22-.02-.33-.03a1.89,1.89,0,0,1,.08.53,2,2,0,0,1-3.91.6,3.36,3.36,0,0,1-3.18,0,2,2,0,0,1-3.91-.6,1.89,1.89,0,0,1,.08-.53c-.11.01-.22.03-.33.03a3.735,3.735,0,0,1-2.97-6.02,2.987,2.987,0,0,1,0-5.96A3.735,3.735,0,0,1,6.25,3c.11,0,.22.02.33.03A1.836,1.836,0,0,1,6.5,2.5a2,2,0,0,1,4-.1,3.013,3.013,0,0,1,3,0,2,2,0,0,1,4,.1,1.836,1.836,0,0,1-.08.53c.11-.01.22-.03.33-.03a3.735,3.735,0,0,1,2.97,6.02,2.987,2.987,0,0,1,0,5.96Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M12,2a3.026,3.026,0,0,0-1.5.4,2,2,0,0,0-4,.1,1.836,1.836,0,0,0,.08.53C6.47,3.02,6.36,3,6.25,3A3.735,3.735,0,0,0,3.28,9.02a2.987,2.987,0,0,0,0,5.96A3.735,3.735,0,0,0,6.25,21c.11,0,.22-.02.33-.03a1.89,1.89,0,0,0-.08.53,2,2,0,0,0,3.91.6,3.377,3.377,0,0,0,1.59.4Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M9.477,17.251A3.251,3.251,0,0,0,6.227,14c-.053,0-.1.013-.153.016"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.14,19.62a1.991,1.991,0,0,1,1.27,2.48"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.5,2.4v.1A2,2,0,0,1,9.14,4.39"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.074,9.986c.052,0,.1.015.153.015a3.251,3.251,0,0,0,3.25-3.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.523,17.251A3.251,3.251,0,0,1,17.773,14c.053,0,.1.013.153.016"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20.72,14.98A3.735,3.735,0,0,1,17.75,21c-.11,0-.22-.02-.33-.03a1.89,1.89,0,0,1,.08.53,2,2,0,0,1-3.91.6,3.36,3.36,0,0,1-3.18,0,2,2,0,0,1-3.91-.6,1.89,1.89,0,0,1,.08-.53c-.11.01-.22.03-.33.03a3.735,3.735,0,0,1-2.97-6.02,2.987,2.987,0,0,1,0-5.96A3.735,3.735,0,0,1,6.25,3c.11,0,.22.02.33.03A1.836,1.836,0,0,1,6.5,2.5a2,2,0,0,1,4-.1,3.013,3.013,0,0,1,3,0,2,2,0,0,1,4,.1,1.836,1.836,0,0,1-.08.53c.11-.01.22-.03.33-.03a3.735,3.735,0,0,1,2.97,6.02,2.987,2.987,0,0,1,0,5.96Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.59,22.1a1.991,1.991,0,0,1,1.27-2.48"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.5,2.4v.1a2,2,0,0,0,1.36,1.89"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.926,9.986c-.052,0-.1.015-.153.015a3.251,3.251,0,0,1-3.25-3.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={12}
|
||||
y1={7.001}
|
||||
x2={12}
|
||||
y2={17.501}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
222
apps/web/modules/ui/components/icons/bug-blue-icon.tsx
Normal file
222
apps/web/modules/ui/components/icons/bug-blue-icon.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
export const BugBlueIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"flying-insect-ladybug"}</title>
|
||||
<path d="M16,6.436V4.5a4,4,0,0,0-8,0V6.436" fill="#00e6ca" />
|
||||
<path
|
||||
d="M12,.5a4,4,0,0,0-4,4V6.436h.04a3.977,3.977,0,0,1,7.92,0H16V4.5A4,4,0,0,0,12,.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M16,6.436V4.5a4,4,0,0,0-8,0V6.436"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx={12} cy={14.5} r={9} fill="#c4f0eb" />
|
||||
<path d="M12,19.115a9,9,0,0,1-8.72-6.808,9,9,0,1,0,17.44,0A9,9,0,0,1,12,19.115Z" fill="#00e6ca" />
|
||||
<line
|
||||
x1={9.115}
|
||||
y1={1.73}
|
||||
x2={8.5}
|
||||
y2={0.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={14.885}
|
||||
y1={1.73}
|
||||
x2={15.5}
|
||||
y2={0.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={12}
|
||||
cy={14.5}
|
||||
r={3.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M17.319,21.75a2.5,2.5,0,0,1,3-3.793"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20.286,10.98a2.679,2.679,0,0,1-1.036.228,2.5,2.5,0,0,1-2-4"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M6.719,21.789a2.629,2.629,0,0,0,.532-1.541,2.5,2.5,0,0,0-2.5-2.5A2.763,2.763,0,0,0,3.7,17.98"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3.713,10.983a2.677,2.677,0,0,0,1.037.225,2.5,2.5,0,0,0,2-4"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<polyline
|
||||
points="18.383 8.156 20 6 21.5 6"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points="18.391 20.837 19.5 22.5 21 22.5"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points="5.617 8.156 4 6 2.5 6"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points="5.609 20.837 4.5 22.5 3 22.5"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={20.678}
|
||||
y1={12.107}
|
||||
x2={22.5}
|
||||
y2={11.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={20.792}
|
||||
y1={16.431}
|
||||
x2={22.5}
|
||||
y2={17}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={3.322}
|
||||
y1={12.107}
|
||||
x2={1.5}
|
||||
y2={11.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={3.208}
|
||||
y1={16.431}
|
||||
x2={1.5}
|
||||
y2={17}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M10.25,3a.25.25,0,0,1,.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M10,3.25A.25.25,0,0,1,10.25,3"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M10.25,3.5A.25.25,0,0,1,10,3.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M10.5,3.25a.25.25,0,0,1-.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M13.75,3a.25.25,0,0,1,.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M13.5,3.25A.25.25,0,0,1,13.75,3"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M13.75,3.5a.25.25,0,0,1-.25-.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M14,3.25a.25.25,0,0,1-.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={12}
|
||||
cy={14.5}
|
||||
r={9}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={12}
|
||||
y1={5.5}
|
||||
x2={12}
|
||||
y2={23.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
221
apps/web/modules/ui/components/icons/bug-icon.tsx
Normal file
221
apps/web/modules/ui/components/icons/bug-icon.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
export const BugIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width={40} height={40} viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M26.6668 10.7263V7.49967C26.6668 5.73156 25.9645 4.03587 24.7142 2.78563C23.464 1.53539 21.7683 0.833008 20.0002 0.833008C18.2321 0.833008 16.5364 1.53539 15.2861 2.78563C14.0359 4.03587 13.3335 5.73156 13.3335 7.49967V10.7263"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
<path
|
||||
d="M20.0002 0.833008C18.2321 0.833008 16.5364 1.53539 15.2861 2.78563C14.0359 4.03587 13.3335 5.73156 13.3335 7.49967V10.7263H13.4002C13.5527 9.08144 14.3141 7.5528 15.5349 6.43994C16.7558 5.32708 18.3482 4.71022 20.0002 4.71022C21.6521 4.71022 23.2445 5.32708 24.4654 6.43994C25.6863 7.5528 26.4476 9.08144 26.6002 10.7263H26.6668V7.49967C26.6668 5.73156 25.9645 4.03587 24.7142 2.78563C23.464 1.53539 21.7683 0.833008 20.0002 0.833008Z"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
<path
|
||||
d="M26.6668 10.7263V7.49967C26.6668 5.73156 25.9645 4.03587 24.7142 2.78563C23.464 1.53539 21.7683 0.833008 20.0002 0.833008C18.2321 0.833008 16.5364 1.53539 15.2861 2.78563C14.0359 4.03587 13.3335 5.73156 13.3335 7.49967V10.7263"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 39.167C28.2843 39.167 35 32.4513 35 24.167C35 15.8827 28.2843 9.16699 20 9.16699C11.7157 9.16699 5 15.8827 5 24.167C5 32.4513 11.7157 39.167 20 39.167Z"
|
||||
fill="#FEE2E2"
|
||||
/>
|
||||
<path
|
||||
d="M20 31.8584C16.66 31.8551 13.4167 30.7371 10.7841 28.6817C8.15147 26.6263 6.28013 23.7511 5.46667 20.5117C4.90089 22.7266 4.84884 25.0415 5.31448 27.2795C5.78011 29.5176 6.75113 31.6196 8.15334 33.4251C9.55555 35.2305 11.3519 36.6915 13.4051 37.6966C15.4583 38.7017 17.714 39.2242 20 39.2242C22.286 39.2242 24.5417 38.7017 26.5949 37.6966C28.6481 36.6915 30.4444 35.2305 31.8467 33.4251C33.2489 31.6196 34.2199 29.5176 34.6855 27.2795C35.1512 25.0415 35.0991 22.7266 34.5333 20.5117C33.7199 23.7511 31.8485 26.6263 29.2159 28.6817C26.5833 30.7371 23.34 31.8551 20 31.8584Z"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
<path
|
||||
d="M15.1915 2.88301L14.1665 0.833008"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M24.8082 2.88301L25.8332 0.833008"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.9998 29.9997C23.2215 29.9997 25.8332 27.388 25.8332 24.1663C25.8332 20.9447 23.2215 18.333 19.9998 18.333C16.7782 18.333 14.1665 20.9447 14.1665 24.1663C14.1665 27.388 16.7782 29.9997 19.9998 29.9997Z"
|
||||
fill="#EF4444"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M28.8651 36.25C28.3117 35.5141 28.0176 34.6156 28.0289 33.6949C28.0401 32.7742 28.356 31.8832 28.9272 31.161C29.4984 30.4388 30.2927 29.9262 31.1861 29.7032C32.0794 29.4802 33.0215 29.5593 33.8651 29.9284"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
<path
|
||||
d="M28.8651 36.25C28.3117 35.5141 28.0176 34.6156 28.0289 33.6949C28.0401 32.7742 28.356 31.8832 28.9272 31.161C29.4984 30.4388 30.2927 29.9262 31.1861 29.7032C32.0794 29.4802 33.0215 29.5593 33.8651 29.9284"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M33.8098 18.3003C33.2654 18.5408 32.6783 18.67 32.0832 18.6803C31.3094 18.6803 30.5509 18.4649 29.8926 18.0581C29.2344 17.6512 28.7024 17.0692 28.3564 16.3771C28.0103 15.685 27.8639 14.9102 27.9333 14.1395C28.0028 13.3688 28.2856 12.6327 28.7498 12.0137"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
<path
|
||||
d="M33.8098 18.3003C33.2654 18.5408 32.6783 18.67 32.0832 18.6803C31.3094 18.6803 30.5509 18.4649 29.8926 18.0581C29.2344 17.6512 28.7024 17.0692 28.3564 16.3771C28.0103 15.685 27.8639 14.9102 27.9333 14.1395C28.0028 13.3688 28.2856 12.6327 28.7498 12.0137"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.1984 36.3151C11.7586 35.5745 12.0691 34.6752 12.0851 33.7467C12.0851 32.6417 11.6461 31.5819 10.8647 30.8005C10.0833 30.0191 9.02348 29.5801 7.91841 29.5801C7.31482 29.5929 6.71965 29.7243 6.16675 29.9667"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
<path
|
||||
d="M11.1984 36.3151C11.7586 35.5745 12.0691 34.6752 12.0851 33.7467C12.0851 32.6417 11.6461 31.5819 10.8647 30.8005C10.0833 30.0191 9.02348 29.5801 7.91841 29.5801C7.31482 29.5929 6.71965 29.7243 6.16675 29.9667"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.18811 18.3053C6.73343 18.5444 7.32108 18.6719 7.91644 18.6803C8.69024 18.6803 9.44876 18.4649 10.107 18.0581C10.7652 17.6512 11.2972 17.0692 11.6432 16.3771C11.9893 15.685 12.1358 14.9102 12.0663 14.1395C11.9968 13.3688 11.7141 12.6327 11.2498 12.0137"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
<path
|
||||
d="M6.18811 18.3053C6.73343 18.5444 7.32108 18.6719 7.91644 18.6803C8.69024 18.6803 9.44876 18.4649 10.107 18.0581C10.7652 17.6512 11.2972 17.0692 11.6432 16.3771C11.9893 15.685 12.1358 14.9102 12.0663 14.1395C11.9968 13.3688 11.7141 12.6327 11.2498 12.0137"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.6382 13.5933L33.3332 10H35.8332"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.6519 34.7285L32.5002 37.5002H35.0002"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.3615 13.5933L6.6665 10H4.1665"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.34833 34.7285L7.5 37.5002H5"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M34.4633 20.1787L37.4999 19.167"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M34.6536 27.3848L37.5002 28.3331"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.53667 20.1787L2.5 19.167"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.34667 27.3848L2.5 28.3331"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.0835 5C17.194 5 17.3 5.0439 17.3781 5.12204C17.4563 5.20018 17.5002 5.30616 17.5002 5.41667"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.6665 5.41667C16.6665 5.30616 16.7104 5.20018 16.7885 5.12204C16.8667 5.0439 16.9727 5 17.0832 5"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.0832 5.83366C16.9727 5.83366 16.8667 5.78976 16.7885 5.71162C16.7104 5.63348 16.6665 5.5275 16.6665 5.41699"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.5002 5.41699C17.5002 5.5275 17.4563 5.63348 17.3781 5.71162C17.3 5.78976 17.194 5.83366 17.0835 5.83366"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M22.9165 5C23.027 5 23.133 5.0439 23.2111 5.12204C23.2893 5.20018 23.3332 5.30616 23.3332 5.41667"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M22.5 5.41667C22.5 5.30616 22.5439 5.20018 22.622 5.12204C22.7002 5.0439 22.8062 5 22.9167 5"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M22.9167 5.83366C22.8062 5.83366 22.7002 5.78976 22.622 5.71162C22.5439 5.63348 22.5 5.5275 22.5 5.41699"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M23.3332 5.41699C23.3332 5.5275 23.2893 5.63348 23.2111 5.71162C23.133 5.78976 23.027 5.83366 22.9165 5.83366"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 39.167C28.2843 39.167 35 32.4513 35 24.167C35 15.8827 28.2843 9.16699 20 9.16699C11.7157 9.16699 5 15.8827 5 24.167C5 32.4513 11.7157 39.167 20 39.167Z"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 9.16699V39.167"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
export const CancelSubscriptionIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M21.566,17.945a1,1,0,0,0,0-.894l-.516-1.034a1,1,0,0,1,.062-1l.308-.462a1,1,0,0,0,0-1.11L20.79,12.5H12.605l-.631-4.12-1.138.758a2.994,2.994,0,0,0-.888.922l-3.3,5.361a1.989,1.989,0,0,1-1,.821L2.29,17.5v5a1,1,0,0,0,1,1h6l1.967-.786a3,3,0,0,1,1.111-.214H19.79l1.062-1.065a1,1,0,0,0,.243-1.023l-.175-.521a1,1,0,0,1,.055-.763Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M20.921,19.891a.807.807,0,0,1-.014-.11c-8.23.8-14.712-.906-9.72-10.877l-.351.235a2.979,2.979,0,0,0-.888.921l-3.3,5.361a2,2,0,0,1-1,.821L2.29,17.5v5a1,1,0,0,0,1,1h6l1.967-.786a3,3,0,0,1,1.111-.214H19.79l1.063-1.065a1,1,0,0,0,.242-1.023Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M12.59,12.5,10.873,1.16a.6.6,0,0,1,.6-.66h8.24a.6.6,0,0,1,.593.689L18.607,12.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M12.289,10.5,10.875,1.074A.5.5,0,0,1,11.37.5h6.919"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M14.789,12.5l-.945-9.45a.5.5,0,0,1,.5-.55h6.867a.5.5,0,0,1,.494.574L20.289,12.5"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M12.29,12.5h8.5l.63.945a1,1,0,0,1,0,1.11l-.308.462a1,1,0,0,0-.062,1l.517,1.034a1,1,0,0,1,0,.894l-.592,1.183a1,1,0,0,0-.054.763l.174.521a1,1,0,0,1-.242,1.023L19.79,22.5H12.368a2.992,2.992,0,0,0-1.114.215L9.29,23.5"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M2.29,17.5l3.358-1.259a2,2,0,0,0,1-.825l3.3-5.357a3.007,3.007,0,0,1,.891-.923l1.135-.757"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M21.211,2.5H14.344a.5.5,0,0,0-.5.55l.666,6.659,7.054-7.054A.5.5,0,0,0,21.211,2.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M14.789,12.5l-.945-9.45a.5.5,0,0,1,.5-.55h6.867a.5.5,0,0,1,.494.574L20.289,12.5"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M17.54,9a1.25,1.25,0,1,0-1.25-1.25A1.25,1.25,0,0,0,17.54,9Z"
|
||||
fill="#f8fafc"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
export const CashCalculatorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path d="M23.5,13.5v8a2,2,0,0,1-2,2h-7a2.006,2.006,0,0,1-2-2v-8Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M21.5,21.5h-7a2.006,2.006,0,0,1-2-2v2a2.006,2.006,0,0,0,2,2h7a2,2,0,0,0,2-2v-2A2,2,0,0,1,21.5,21.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M23.5,13.5v8a2,2,0,0,1-2,2h-7a2.006,2.006,0,0,1-2-2v-8Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.5,10.5H1.5a1,1,0,0,1-1-1v-8a1,1,0,0,1,1-1h16a1,1,0,0,1,1,1l0,8A1,1,0,0,1,17.5,10.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M17.5,10.5H1.5a1,1,0,0,1-1-1v2a1,1,0,0,0,1,1h16a1,1,0,0,0,1-1V9.531A1,1,0,0,1,17.5,10.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path d="M17.5,12.5H1.5v1a1,1,0,0,0,1,1h14a1,1,0,0,0,1-1v-1Z" fill="#c4f0eb" />
|
||||
<path d="M1.5.5a1,1,0,0,0-1,1v8a1,1,0,0,0,.2.6L10.3.5Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M10.5,10.5h-9a1,1,0,0,1-1-1v-8a1,1,0,0,1,1-1h16a1,1,0,0,1,1,1v5"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M.5,4,4,.5" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18.5,4,15,.5" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M.5,7,4,10.5" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path
|
||||
d="M11.5,3.5H8.346a.843.843,0,0,0-.2,1.66l2.724.68a.843.843,0,0,1-.2,1.66H7.5"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M9.5,3.5v-1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M9.5,8.5v-1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14.5,15.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17.5,15.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M20.5,15.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14.5,17.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17.5,17.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M20.5,17.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14.5,19.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17.5,19.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M20.5,19.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14.5,21.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17.5,21.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M20.5,21.5h1" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M23.5,10.5v3h-11v-3a2.006,2.006,0,0,1,2-2h7A2,2,0,0,1,23.5,10.5Z" fill="#00e6ca" />
|
||||
<path d="M12.5,10.5v3h2.124l5-5H14.5A2.006,2.006,0,0,0,12.5,10.5Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M23.5,10.5v3h-11v-3a2.006,2.006,0,0,1,2-2h7A2,2,0,0,1,23.5,10.5Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M1.5,12.5h9" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2.5,14.5h8" fill="none" stroke="#0f172a" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
15
apps/web/modules/ui/components/icons/check-mark-icon.tsx
Normal file
15
apps/web/modules/ui/components/icons/check-mark-icon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const CheckMarkIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<circle cx={12} cy={12} r={12} fill="#c4f0eb" />
|
||||
<polyline
|
||||
points="23.5 0.499 7 23.499 0.5 16.999"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
19
apps/web/modules/ui/components/icons/clock-icon.tsx
Normal file
19
apps/web/modules/ui/components/icons/clock-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export const ClockIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<circle cx="{11.999}" cy="{12.001}" r="{11.5}" fill="#00e6ca" />
|
||||
<path d="M3.867,20.133A11.5,11.5,0,0,1,20.131,3.869Z" fill="#c4f0eb" />
|
||||
<circle
|
||||
cx="{11.999}"
|
||||
cy="{12.001}"
|
||||
r="{11.5}"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline points="12 6.501 12 12.001 18 17.501" fill="none" stroke="#0f172a" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
44
apps/web/modules/ui/components/icons/code-book-icon.tsx
Normal file
44
apps/web/modules/ui/components/icons/code-book-icon.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
export const CodeBookIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path d="M19.5,4.5v-3a1,1,0,0,0-1-1H5.5a2,2,0,0,0,0,4Z" fill="#c4f0eb" />
|
||||
<path d="M3.5,2.5a2,2,0,0,0,2,2h14a1,1,0,0,1,1,1v17a1,1,0,0,1-1,1H5.5a2,2,0,0,1-2-2Z" fill="#00e6ca" />
|
||||
<path
|
||||
d="M19.5,4.5H5.5a2,2,0,0,1-2-2v3a2,2,0,0,0,2,2h14a1,1,0,0,1,1,1v-3A1,1,0,0,0,19.5,4.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M5.5,2.5h11a1,1,0,0,1,1,1v1"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.5,4.5v-3a1,1,0,0,0-1-1H5.5a2,2,0,0,0,0,4h14a1,1,0,0,1,1,1v17a1,1,0,0,1-1,1H5.5a2,2,0,0,1-2-2V2.5"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="7.5 10.504 10 13.004 7.5 15.504"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={12.5}
|
||||
y1={14.504}
|
||||
x2={16.5}
|
||||
y2={14.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
33
apps/web/modules/ui/components/icons/code-file-icon.tsx
Normal file
33
apps/web/modules/ui/components/icons/code-file-icon.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export const CodeFileIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M21.207,4.5a1,1,0,0,1,.293.707V22.5a1,1,0,0,1-1,1H3.5a1,1,0,0,1-1-1V1.5a1,1,0,0,1,1-1H16.793A1,1,0,0,1,17.5.8Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path d="M19.352,2.648,17.5.8A1,1,0,0,0,16.793.5H3.5a1,1,0,0,0-1,1v18Z" fill="#f8fafc" />
|
||||
<polyline
|
||||
points="10 9.004 6.5 12.504 10 16.004"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="14 9.004 17.5 12.504 14 16.004"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M21.207,4.5a1,1,0,0,1,.293.707V22.5a1,1,0,0,1-1,1H3.5a1,1,0,0,1-1-1V1.5a1,1,0,0,1,1-1H16.793A1,1,0,0,1,17.5.8Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
49
apps/web/modules/ui/components/icons/compliment-icon.tsx
Normal file
49
apps/web/modules/ui/components/icons/compliment-icon.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
export const ComplimentIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width={40} height={40} viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_78_2670)">
|
||||
<path
|
||||
d="M20.0002 39.1663C30.5856 39.1663 39.1668 30.5851 39.1668 19.9997C39.1668 9.41422 30.5856 0.833008 20.0002 0.833008C9.41471 0.833008 0.833496 9.41422 0.833496 19.9997C0.833496 30.5851 9.41471 39.1663 20.0002 39.1663Z"
|
||||
fill="#10B981"
|
||||
/>
|
||||
<path
|
||||
d="M20.0002 7.49967C24.1409 7.49999 28.1851 8.75057 31.6032 11.0877C35.0213 13.4248 37.6541 16.7396 39.1568 20.598C39.1568 20.398 39.1668 20.1997 39.1668 19.9997C39.1668 14.9164 37.1475 10.0412 33.553 6.44679C29.9586 2.85235 25.0835 0.833008 20.0002 0.833008C14.9168 0.833008 10.0417 2.85235 6.44728 6.44679C2.85284 10.0412 0.833496 14.9164 0.833496 19.9997C0.833496 20.1997 0.833496 20.398 0.843496 20.598C2.34626 16.7396 4.97904 13.4248 8.39715 11.0877C11.8153 8.75057 15.8594 7.49999 20.0002 7.49967Z"
|
||||
fill="#ECFDF5"
|
||||
/>
|
||||
<path
|
||||
d="M8.94836 22.5C10.0107 24.5102 11.6011 26.1925 13.5485 27.366C15.4959 28.5395 17.7264 29.1596 20 29.1596C22.2736 29.1596 24.5042 28.5395 26.4516 27.366C28.3989 26.1925 29.9894 24.5102 31.0517 22.5"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20.0002 39.1663C30.5856 39.1663 39.1668 30.5851 39.1668 19.9997C39.1668 9.41422 30.5856 0.833008 20.0002 0.833008C9.41471 0.833008 0.833496 9.41422 0.833496 19.9997C0.833496 30.5851 9.41471 39.1663 20.0002 39.1663Z"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.1665 13.3331C9.73077 12.5381 10.482 11.8942 11.354 11.4582C12.226 11.0222 13.1919 10.8075 14.1665 10.8331"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.8335 13.3331C30.2692 12.5381 29.518 11.8942 28.646 11.4582C27.774 11.0222 26.8081 10.8075 25.8335 10.8331"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_78_2670">
|
||||
<rect width={40} height={40} fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
27
apps/web/modules/ui/components/icons/cross-mark-icon.tsx
Normal file
27
apps/web/modules/ui/components/icons/cross-mark-icon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
export const CrossMarkIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<line
|
||||
x1={23.5}
|
||||
y1={0.5}
|
||||
x2={0.5}
|
||||
y2={23.5}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={23.5}
|
||||
y1={23.5}
|
||||
x2={0.5}
|
||||
y2={0.5}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
73
apps/web/modules/ui/components/icons/customers-icon.tsx
Normal file
73
apps/web/modules/ui/components/icons/customers-icon.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
export const CustomersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_111_733)">
|
||||
<path
|
||||
d="M7.81248 24.6169L8.33331 18.3669H10.9375V14.721C10.9375 13.3397 10.3887 12.0149 9.41199 11.0382C8.43524 10.0614 7.11048 9.5127 5.72915 9.5127C4.34781 9.5127 3.02305 10.0614 2.0463 11.0382C1.06955 12.0149 0.520813 13.3397 0.520813 14.721V18.3669H3.12498L3.64581 24.6169H7.81248Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M5.72915 7.94987C7.74268 7.94987 9.37498 6.31757 9.37498 4.30404C9.37498 2.2905 7.74268 0.658203 5.72915 0.658203C3.71561 0.658203 2.08331 2.2905 2.08331 4.30404C2.08331 6.31757 3.71561 7.94987 5.72915 7.94987Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M9.37501 4.30404C9.37645 4.66062 9.32377 5.01535 9.21876 5.35612C8.99211 4.60706 8.53044 3.95082 7.90202 3.48441C7.2736 3.018 6.51177 2.76618 5.72918 2.76618C4.94658 2.76618 4.18476 3.018 3.55633 3.48441C2.92791 3.95082 2.46624 4.60706 2.23959 5.35612C2.13458 5.01535 2.0819 4.66062 2.08334 4.30404C2.08334 3.3371 2.46746 2.40977 3.15118 1.72604C3.83491 1.04232 4.76224 0.658203 5.72918 0.658203C6.69611 0.658203 7.62344 1.04232 8.30717 1.72604C8.9909 2.40977 9.37501 3.3371 9.37501 4.30404Z"
|
||||
fill="#F8FAFC"
|
||||
/>
|
||||
<path
|
||||
d="M5.72915 9.5127C4.34781 9.5127 3.02305 10.0614 2.0463 11.0382C1.06955 12.0149 0.520813 13.3397 0.520813 14.721V16.8242C0.520813 15.4428 1.06955 14.1181 2.0463 13.1413C3.02305 12.1646 4.34781 11.6158 5.72915 11.6158C7.11048 11.6158 8.43524 12.1646 9.41199 13.1413C10.3887 14.1181 10.9375 15.4428 10.9375 16.8242V14.721C10.9375 13.3397 10.3887 12.0149 9.41199 11.0382C8.43524 10.0614 7.11048 9.5127 5.72915 9.5127Z"
|
||||
fill="#F8FAFC"
|
||||
/>
|
||||
<path
|
||||
d="M7.81248 24.6169L8.33331 18.3669H10.9375V14.721C10.9375 13.3397 10.3887 12.0149 9.41199 11.0382C8.43524 10.0614 7.11048 9.5127 5.72915 9.5127C4.34781 9.5127 3.02305 10.0614 2.0463 11.0382C1.06955 12.0149 0.520813 13.3397 0.520813 14.721V18.3669H3.12498L3.64581 24.6169H7.81248Z"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.72915 7.94987C7.74268 7.94987 9.37498 6.31757 9.37498 4.30404C9.37498 2.2905 7.74268 0.658203 5.72915 0.658203C3.71561 0.658203 2.08331 2.2905 2.08331 4.30404C2.08331 6.31757 3.71561 7.94987 5.72915 7.94987Z"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M21.3667 24.6169L21.8875 18.3669H24.4917V14.721C24.4917 13.3397 23.9429 12.0149 22.9662 11.0382C21.9894 10.0614 20.6647 9.5127 19.2833 9.5127C17.902 9.5127 16.5773 10.0614 15.6005 11.0382C14.6237 12.0149 14.075 13.3397 14.075 14.721V18.3669H16.6792L17.2 24.6169H21.3667Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M19.2833 7.94987C21.2968 7.94987 22.9291 6.31757 22.9291 4.30404C22.9291 2.2905 21.2968 0.658203 19.2833 0.658203C17.2697 0.658203 15.6375 2.2905 15.6375 4.30404C15.6375 6.31757 17.2697 7.94987 19.2833 7.94987Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M22.9291 4.30404C22.9306 4.66062 22.8779 5.01535 22.7729 5.35612C22.5462 4.60706 22.0846 3.95082 21.4562 3.48441C20.8277 3.018 20.0659 2.76618 19.2833 2.76618C18.5007 2.76618 17.7389 3.018 17.1105 3.48441C16.482 3.95082 16.0204 4.60706 15.7937 5.35612C15.6887 5.01535 15.636 4.66062 15.6375 4.30404C15.6375 3.3371 16.0216 2.40977 16.7053 1.72604C17.389 1.04232 18.3164 0.658203 19.2833 0.658203C20.2502 0.658203 21.1776 1.04232 21.8613 1.72604C22.545 2.40977 22.9291 3.3371 22.9291 4.30404Z"
|
||||
fill="#F8FAFC"
|
||||
/>
|
||||
<path
|
||||
d="M19.2833 9.5127C17.902 9.5127 16.5773 10.0614 15.6005 11.0382C14.6237 12.0149 14.075 13.3397 14.075 14.721V16.8242C14.075 15.4428 14.6237 14.1181 15.6005 13.1413C16.5773 12.1646 17.902 11.6158 19.2833 11.6158C20.6647 11.6158 21.9894 12.1646 22.9662 13.1413C23.9429 14.1181 24.4917 15.4428 24.4917 16.8242V14.721C24.4917 13.3397 23.9429 12.0149 22.9662 11.0382C21.9894 10.0614 20.6647 9.5127 19.2833 9.5127Z"
|
||||
fill="#F8FAFC"
|
||||
/>
|
||||
<path
|
||||
d="M21.3667 24.6169L21.8875 18.3669H24.4917V14.721C24.4917 13.3397 23.9429 12.0149 22.9662 11.0382C21.9894 10.0614 20.6647 9.5127 19.2833 9.5127C17.902 9.5127 16.5772 10.0614 15.6005 11.0382C14.6237 12.0149 14.075 13.3397 14.075 14.721V18.3669H16.6792L17.2 24.6169H21.3667Z"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.2833 7.94987C21.2968 7.94987 22.9291 6.31757 22.9291 4.30404C22.9291 2.2905 21.2968 0.658203 19.2833 0.658203C17.2697 0.658203 15.6375 2.2905 15.6375 4.30404C15.6375 6.31757 17.2697 7.94987 19.2833 7.94987Z"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_733">
|
||||
<rect width={25} height={25} fill="white" transform="translate(0 0.137695)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
77
apps/web/modules/ui/components/icons/dashboard-icon.tsx
Normal file
77
apps/web/modules/ui/components/icons/dashboard-icon.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
export const DashboardIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<rect x={0.5} y={16.5} width={10} height={7} rx={1} fill="#00e6ca" />
|
||||
<path d="M5.5,16.5h-4a1,1,0,0,0-1,1v5a1,1,0,0,0,1,1h4Z" fill="#c4f0eb" />
|
||||
<rect
|
||||
x={13.5}
|
||||
y={10.5}
|
||||
width={10}
|
||||
height={13}
|
||||
rx={1}
|
||||
transform="translate(37 34) rotate(180)"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M18.5,10.5h-4a1,1,0,0,0-1,1v11a1,1,0,0,0,1,1h4Z" fill="#c4f0eb" />
|
||||
<rect
|
||||
x={13.5}
|
||||
y={0.5}
|
||||
width={10}
|
||||
height={7}
|
||||
rx={1}
|
||||
transform="translate(37 8) rotate(180)"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M18.5.5h-4a1,1,0,0,0-1,1v5a1,1,0,0,0,1,1h4Z" fill="#c4f0eb" />
|
||||
<rect x={0.5} y={0.5} width={10} height={13} rx={1} fill="#00e6ca" />
|
||||
<path d="M5.5.5h-4a1,1,0,0,0-1,1v11a1,1,0,0,0,1,1h4Z" fill="#c4f0eb" />
|
||||
<rect
|
||||
x={0.5}
|
||||
y={16.5}
|
||||
width={10}
|
||||
height={7}
|
||||
rx={1}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect
|
||||
x={13.5}
|
||||
y={10.5}
|
||||
width={10}
|
||||
height={13}
|
||||
rx={1}
|
||||
transform="translate(37 34) rotate(180)"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect
|
||||
x={13.5}
|
||||
y={0.5}
|
||||
width={10}
|
||||
height={7}
|
||||
rx={1}
|
||||
transform="translate(37 8) rotate(180)"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect
|
||||
x={0.5}
|
||||
y={0.5}
|
||||
width={10}
|
||||
height={13}
|
||||
rx={1}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
65
apps/web/modules/ui/components/icons/dog-chaser-icon.tsx
Normal file
65
apps/web/modules/ui/components/icons/dog-chaser-icon.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
export const DogChaserIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"dog-jump"}</title>
|
||||
<path
|
||||
d="M1.8,9.118C3.3,8.618,8.187,7.5,10.687,6.5a40.6,40.6,0,0,0,5-2.5l1.5-2a5.473,5.473,0,0,1,1,2c1.5,0,2,2,2,2l1.321.264a1,1,0,0,1,.733,1.352l-.252.63a.994.994,0,0,1-1.051.621A8.937,8.937,0,0,0,17.687,9a6.813,6.813,0,0,0-3,1.5s3.5-.5,4.5,1c.785,1.177,1,2,0,2.5-1.612.806-3-1-3-1s-3.514,1.172-4.677-.627h0S7.187,13,5.687,14A2.8,2.8,0,0,1,4.68,17.541L2.492,19.157A32.313,32.313,0,0,1,1.8,9.118Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M19.74,4.987A1.858,1.858,0,0,0,18.187,4a5.473,5.473,0,0,0-1-2l-1.5,2a40.6,40.6,0,0,1-5,2.5C8.187,7.5,3.3,8.618,1.8,9.118c-.089.891-.123,1.8-.124,2.719C12.4,9.229,16.365,5.166,19.74,4.987Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M11.51,12.373a2.284,2.284,0,0,1-.323-.873"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.8,9.118C3.3,8.618,8.187,7.5,10.687,6.5a40.6,40.6,0,0,0,5-2.5l1.5-2a5.473,5.473,0,0,1,1,2c1.5,0,2,2,2,2l1.321.264a1,1,0,0,1,.733,1.352l-.252.63a.994.994,0,0,1-1.051.621A8.937,8.937,0,0,0,17.687,9a6.813,6.813,0,0,0-3,1.5s3.5-.5,4.5,1c.785,1.177,1,2,0,2.5-1.612.806-3-1-3-1s-3.514,1.172-4.677-.627h0S7.187,13,5.687,14A2.8,2.8,0,0,1,4.68,17.541"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.044,5.5c-.55-2.436-1.4-4-2.357-4-1.657,0-3,4.7-3,10.5s1.343,10.5,3,10.5c1.359,0,2.631-2.663,3-7"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.937,5.5a.25.25,0,0,0-.25.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.187,5.75a.25.25,0,0,0-.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.937,6a.25.25,0,0,0,.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.687,5.75a.25.25,0,0,0,.25.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
33
apps/web/modules/ui/components/icons/door-icon.tsx
Normal file
33
apps/web/modules/ui/components/icons/door-icon.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export const DoorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"architecture-door"}</title>
|
||||
<path d="M5,20.5V1.5a1,1,0,0,1,1-1H18a1,1,0,0,1,1,1v19Z" fill="#00e6ca" />
|
||||
<path d="M18,.5H6a1,1,0,0,0-1,1v3a1,1,0,0,1,1-1H18a1,1,0,0,1,1,1v-3A1,1,0,0,0,18,.5Z" fill="#00e6ca" />
|
||||
<circle
|
||||
cx={15.501}
|
||||
cy={11}
|
||||
r={1.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M5,20.5V1.5a1,1,0,0,1,1-1H18a1,1,0,0,1,1,1v19"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M21,21.5a1,1,0,0,0-1-1H4a1,1,0,0,0-1,1V23a.5.5,0,0,0,.5.5h17A.5.5,0,0,0,21,23Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
47
apps/web/modules/ui/components/icons/email-icon.tsx
Normal file
47
apps/web/modules/ui/components/icons/email-icon.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
export const EmailIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path d="M23.3.62,9.23,17.6l-.91,5.9a.8.8,0,0,1-.76-.53l-2.61-7.2Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M23.3.62,4.95,15.77l-3.8-1.63A1.087,1.087,0,0,1,.5,13.2a1.064,1.064,0,0,1,.56-.99L22.71.56A.532.532,0,0,1,23.3.62Z"
|
||||
fill="#f8fafc"
|
||||
/>
|
||||
<path
|
||||
d="M23.48,1.18,18.17,20.11a1.049,1.049,0,0,1-.57.68,1.079,1.079,0,0,1-.88.02l-4.84-2.08L8.99,23.14a.816.816,0,0,1-.67.36l.91-5.9L23.3.62A.519.519,0,0,1,23.48,1.18Z"
|
||||
fill="#f8fafc"
|
||||
/>
|
||||
<line
|
||||
x1={4.953}
|
||||
y1={15.765}
|
||||
x2={23.299}
|
||||
y2={0.617}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.231,17.6l7.489,3.209a1.069,1.069,0,0,0,1.452-.7L23.48,1.18a.535.535,0,0,0-.769-.616L1.063,12.208a1.071,1.071,0,0,0,.086,1.927l3.8,1.63"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.953,15.765l2.6,7.2a.805.805,0,0,0,1.431.168l2.889-4.4"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="23.299 0.617 9.231 17.599 8.315 23.5"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
143
apps/web/modules/ui/components/icons/engineer-icon.tsx
Normal file
143
apps/web/modules/ui/components/icons/engineer-icon.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
export const EngineerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"study-maths-brain"}</title>
|
||||
<path
|
||||
d="M6.851,23.5V21.023H5.932a2.477,2.477,0,0,1-2.478-2.478V16.067H1.343a.5.5,0,0,1-.474-.653c1.1-3.414,2-6.989,4.286-9.081,0,0,2.582-2.559,7.8-1.516s5.46,7.739,5.46,7.739a8.036,8.036,0,0,1-3.3,6.494V23.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M10.406,7.61a12.3,12.3,0,0,1,7.62,2.639c-.551-2.042-1.877-4.793-5.07-5.432-5.219-1.043-7.8,1.516-7.8,1.516a11.057,11.057,0,0,0-2.61,4.1A12.318,12.318,0,0,1,10.406,7.61Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<line
|
||||
x1={17.656}
|
||||
y1={0.501}
|
||||
x2={17.656}
|
||||
y2={4.501}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={19.656}
|
||||
y1={2.501}
|
||||
x2={15.656}
|
||||
y2={2.501}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={20.327}
|
||||
y1={6.001}
|
||||
x2={23.156}
|
||||
y2={8.83}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={23.156}
|
||||
y1={6.001}
|
||||
x2={20.327}
|
||||
y2={8.83}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={15.156}
|
||||
y1={8.497}
|
||||
x2={11.156}
|
||||
y2={8.497}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.156,6.247a.25.25,0,0,0-.25.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.406,6.5a.25.25,0,0,0-.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.156,6.747a.25.25,0,0,0,.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.906,6.5a.25.25,0,0,0,.25.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.156,10.247a.25.25,0,0,0-.25.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.406,10.5a.25.25,0,0,0-.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.156,10.747a.25.25,0,0,0,.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.906,10.5a.25.25,0,0,0,.25.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="7.656 2.001 8.656 3.501 9.656 0.501 13.156 0.501"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.851,23.5V21.023H5.932a2.477,2.477,0,0,1-2.478-2.478V16.067H1.343a.5.5,0,0,1-.474-.653c1.1-3.414,2-6.989,4.286-9.081"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.416,12.556a8.036,8.036,0,0,1-3.3,6.494V23.5"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
96
apps/web/modules/ui/components/icons/eye-icon.tsx
Normal file
96
apps/web/modules/ui/components/icons/eye-icon.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
export const EyeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width="77" height="77" viewBox="0 0 77 77" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_914_595)">
|
||||
<path
|
||||
d="M38.5002 68.9788C55.3333 68.9788 68.9793 55.3329 68.9793 38.4997C68.9793 21.6665 55.3333 8.02051 38.5002 8.02051C21.667 8.02051 8.021 21.6665 8.021 38.4997C8.021 55.3329 21.667 68.9788 38.5002 68.9788Z"
|
||||
fill="#00C4B8"
|
||||
/>
|
||||
<path
|
||||
d="M62.0846 36.3023C62.6525 36.8988 62.9692 37.6909 62.9692 38.5144C62.9692 39.338 62.6525 40.1301 62.0846 40.7266C58.2346 44.7723 49.1229 52.9503 38.5034 52.9503C27.8838 52.9503 18.7817 44.7723 14.9285 40.7266C14.3607 40.1301 14.0439 39.338 14.0439 38.5144C14.0439 37.6909 14.3607 36.8988 14.9285 36.3023C18.7785 32.2566 27.8838 24.0625 38.5002 24.0625C49.1165 24.0625 58.2314 32.2566 62.0846 36.3023Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M16.7218 42.5168C21.2134 38.2433 29.2888 32.0833 38.4999 32.0833C47.7111 32.0833 55.7961 38.2433 60.2877 42.5168C60.9711 41.8752 61.5711 41.2592 62.0812 40.7266C62.6491 40.1301 62.9658 39.338 62.9658 38.5144C62.9658 37.6909 62.6491 36.8988 62.0812 36.3023C58.2312 32.2566 49.1227 24.0625 38.4999 24.0625C27.8772 24.0625 18.7751 32.2437 14.9251 36.2895C14.3572 36.886 14.0405 37.678 14.0405 38.5016C14.0405 39.3252 14.3572 40.1172 14.9251 40.7137C15.4384 41.2592 16.0416 41.8687 16.7218 42.5168Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M62.0846 36.3023C62.6525 36.8988 62.9692 37.6909 62.9692 38.5144C62.9692 39.338 62.6525 40.1301 62.0846 40.7266C58.2346 44.7723 49.1229 52.9503 38.5034 52.9503C27.8838 52.9503 18.7817 44.7723 14.9285 40.7266C14.3607 40.1301 14.0439 39.338 14.0439 38.5144C14.0439 37.6909 14.3607 36.8988 14.9285 36.3023C18.7785 32.2566 27.8838 24.0625 38.5002 24.0625C49.1165 24.0625 58.2314 32.2566 62.0846 36.3023Z"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M38.4998 46.5212C40.0862 46.5212 41.637 46.0508 42.956 45.1694C44.275 44.2881 45.303 43.0354 45.9101 41.5698C46.5172 40.1042 46.676 38.4914 46.3666 36.9355C46.0571 35.3797 45.2932 33.9505 44.1714 32.8287C43.0497 31.707 41.6205 30.9431 40.0646 30.6336C38.5087 30.3241 36.896 30.483 35.4304 31.09C33.9648 31.6971 32.7121 32.7252 31.8308 34.0442C30.9494 35.3632 30.479 36.914 30.479 38.5003C30.479 40.6276 31.3241 42.6677 32.8283 44.1719C34.3324 45.6761 36.3726 46.5212 38.4998 46.5212Z"
|
||||
fill="#00C4B8"
|
||||
/>
|
||||
<path
|
||||
d="M44.1755 32.8403C42.6689 31.3472 40.6321 30.5117 38.5109 30.5166C36.3898 30.5216 34.3569 31.3665 32.8572 32.8667C31.3576 34.3668 30.5131 36.3998 30.5088 38.521C30.5045 40.6422 31.3405 42.6787 32.8341 44.1849L44.1755 32.8403Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M38.4998 46.5212C40.0862 46.5212 41.637 46.0508 42.956 45.1694C44.275 44.2881 45.303 43.0354 45.9101 41.5698C46.5172 40.1042 46.676 38.4914 46.3666 36.9355C46.0571 35.3797 45.2932 33.9505 44.1714 32.8287C43.0497 31.707 41.6205 30.9431 40.0646 30.6336C38.5087 30.3241 36.896 30.483 35.4304 31.09C33.9648 31.6971 32.7121 32.7252 31.8308 34.0442C30.9494 35.3632 30.479 36.914 30.479 38.5003C30.479 40.6276 31.3241 42.6677 32.8283 44.1719C34.3324 45.6761 36.3726 46.5212 38.4998 46.5212V46.5212Z"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M61.7734 9.8623C67.9128 14.8518 72.2859 21.6824 74.2474 29.3465"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.5786 2.47649C38.3012 0.78255 46.3636 1.61252 53.5792 4.84424"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.37061 22.2948C8.85021 15.1902 14.5212 9.39205 21.547 5.75586"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.17825 54.3462C1.77944 47.2019 0.767152 39.1542 2.29075 31.3906"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.1585 74.4591C22.4529 72.6661 15.5278 68.4438 10.4048 62.415"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M61.4201 67.4199C55.2232 72.3384 47.6072 75.1306 39.6997 75.383"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M75.3961 38.5771C75.3833 46.4882 72.8266 54.1853 68.1035 60.5318"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_914_595">
|
||||
<rect width="77" height="77" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
export const FeatureRequestIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M23.4375 73.4375L25 54.6875H32.8125V43.75C32.8125 39.606 31.1663 35.6317 28.236 32.7015C25.3058 29.7712 21.3315 28.125 17.1875 28.125C13.0435 28.125 9.06921 29.7712 6.13896 32.7015C3.2087 35.6317 1.5625 39.606 1.5625 43.75V54.6875H9.375L10.9375 73.4375H23.4375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M17.1875 23.4375C23.2281 23.4375 28.125 18.5406 28.125 12.5C28.125 6.45938 23.2281 1.5625 17.1875 1.5625C11.1469 1.5625 6.25 6.45938 6.25 12.5C6.25 18.5406 11.1469 23.4375 17.1875 23.4375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M32.8125 43.75V50C32.8125 45.856 31.1663 41.8817 28.236 38.9515C25.3058 36.0212 21.3315 34.375 17.1875 34.375C13.0435 34.375 9.06921 36.0212 6.13896 38.9515C3.2087 41.8817 1.5625 45.856 1.5625 50V43.75C1.5625 39.606 3.2087 35.6317 6.13896 32.7015C9.06921 29.7712 13.0435 28.125 17.1875 28.125C21.3315 28.125 25.3058 29.7712 28.236 32.7015C31.1663 35.6317 32.8125 39.606 32.8125 43.75V43.75Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M28.1251 12.5C28.1295 13.5595 27.9714 14.6134 27.6563 15.625C26.9874 13.3676 25.6066 11.3869 23.72 9.97825C21.8334 8.56963 19.542 7.80857 17.1876 7.80857C14.8331 7.80857 12.5417 8.56963 10.6551 9.97825C8.76854 11.3869 7.38781 13.3676 6.71884 15.625C6.4038 14.6134 6.24571 13.5595 6.25009 12.5C6.25009 9.59919 7.40243 6.8172 9.45361 4.76602C11.5048 2.71484 14.2868 1.5625 17.1876 1.5625C20.0884 1.5625 22.8704 2.71484 24.9216 4.76602C26.9727 6.8172 28.1251 9.59919 28.1251 12.5Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M31.975 38.6934C30.7795 35.1905 28.3792 32.2256 25.2018 30.3272C22.0244 28.4287 18.2762 27.7198 14.6249 28.3268C10.9737 28.9339 7.65647 30.8174 5.26428 33.6419C2.87209 36.4663 1.56027 40.0483 1.5625 43.7497V54.6872H9.375L10.9375 73.4372H23.4375L25 54.6872H28.125"
|
||||
stroke="black"
|
||||
strokeWidth="3.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.1875 23.4375C23.2281 23.4375 28.125 18.5406 28.125 12.5C28.125 6.45938 23.2281 1.5625 17.1875 1.5625C11.1469 1.5625 6.25 6.45938 6.25 12.5C6.25 18.5406 11.1469 23.4375 17.1875 23.4375Z"
|
||||
stroke="black"
|
||||
strokeWidth="3.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M54.6875 73.4375C58.3959 73.4375 62.021 72.3378 65.1044 70.2776C68.1879 68.2173 70.5911 65.2889 72.0102 61.8628C73.4294 58.4367 73.8007 54.6667 73.0772 51.0296C72.3538 47.3924 70.568 44.0515 67.9458 41.4293C65.3235 38.807 61.9826 37.0213 58.3454 36.2978C54.7083 35.5743 50.9383 35.9456 47.5122 37.3648C44.0861 38.7839 41.1577 41.1871 39.0974 44.2706C37.0372 47.354 35.9375 50.9791 35.9375 54.6875C35.9375 59.6603 37.9129 64.4295 41.4292 67.9458C44.9456 71.4621 49.7147 73.4375 54.6875 73.4375Z"
|
||||
fill="#00E6CA"
|
||||
/>
|
||||
<path
|
||||
d="M54.6873 35.9375C50.8711 35.9345 47.1449 37.0969 44.0074 39.2694C40.8699 41.4419 38.4707 44.5207 37.1308 48.094C35.7908 51.6672 35.5741 55.5645 36.5096 59.2643C37.4451 62.9641 39.4881 66.2899 42.3654 68.7969L68.7967 42.3656C67.042 40.3467 64.8744 38.7279 62.4402 37.619C60.0061 36.51 57.3622 35.9366 54.6873 35.9375V35.9375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M54.6875 73.4375C58.3959 73.4375 62.021 72.3378 65.1044 70.2776C68.1879 68.2173 70.5911 65.2889 72.0102 61.8628C73.4294 58.4367 73.8007 54.6667 73.0772 51.0296C72.3538 47.3924 70.568 44.0515 67.9458 41.4293C65.3235 38.807 61.9826 37.0213 58.3454 36.2978C54.7083 35.5743 50.9383 35.9456 47.5122 37.3648C44.0861 38.7839 41.1577 41.1871 39.0974 44.2706C37.0372 47.354 35.9375 50.9791 35.9375 54.6875C35.9375 59.6603 37.9129 64.4295 41.4292 67.9458C44.9456 71.4621 49.7147 73.4375 54.6875 73.4375Z"
|
||||
stroke="black"
|
||||
strokeWidth="3.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M54.6875 45.3125V64.0625"
|
||||
stroke="black"
|
||||
strokeWidth="3.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M64.0625 54.6875H45.3125"
|
||||
stroke="black"
|
||||
strokeWidth="3.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
58
apps/web/modules/ui/components/icons/feedback-icon.tsx
Normal file
58
apps/web/modules/ui/components/icons/feedback-icon.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
export const FeedbackIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M23.5,6.5c0-3.313-3.358-6-7.5-6s-7.5,2.687-7.5,6c0,3.179,3.092,5.773,7,5.981V16l4.365-4.365A5.88,5.88,0,0,0,23.5,6.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M16,2.5c3.716,0,6.8,2.163,7.4,5a4.807,4.807,0,0,0,.1-1c0-3.313-3.358-6-7.5-6s-7.5,2.687-7.5,6a4.892,4.892,0,0,0,.1,1C9.2,4.663,12.285,2.5,16,2.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M23.5,6.5c0-3.313-3.358-6-7.5-6s-7.5,2.687-7.5,6c0,3.179,3.092,5.773,7,5.981V16l4.365-4.365A5.88,5.88,0,0,0,23.5,6.5Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={12.501}
|
||||
y1={4.5}
|
||||
x2={19.501}
|
||||
y2={4.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={12.501}
|
||||
y1={7.5}
|
||||
x2={17.501}
|
||||
y2={7.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={3.5}
|
||||
cy={10.25}
|
||||
r={2.25}
|
||||
fill="#c4f0eb"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3.5,13.5a3,3,0,0,0-3,3v2H2l.5,5h2l.5-5H6.5v-2A3,3,0,0,0,3.5,13.5Z"
|
||||
fill="#c4f0eb"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
19
apps/web/modules/ui/components/icons/filter-icon.tsx
Normal file
19
apps/web/modules/ui/components/icons/filter-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export const FilterIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M21.811,2.1a1,1,0,0,0-.8-1.6H2.99a1,1,0,0,0-.8,1.6L10,12.592V22.5a1,1,0,0,0,1,1h2a1,1,0,0,0,1-1V12.592Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M12,.5H2.99a1,1,0,0,0-.8,1.6L10,12.592V22.5a1,1,0,0,0,1,1h1Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M21.811,2.1a1,1,0,0,0-.8-1.6H2.99a1,1,0,0,0-.8,1.6L10,12.592V22.5a1,1,0,0,0,1,1h2a1,1,0,0,0,1-1V12.592Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
106
apps/web/modules/ui/components/icons/form-icon.tsx
Normal file
106
apps/web/modules/ui/components/icons/form-icon.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
export const FormIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_111_716)">
|
||||
<path
|
||||
d="M19.2708 22.5332C19.2708 23.0857 19.0513 23.6156 18.6606 24.0063C18.2699 24.397 17.74 24.6165 17.1875 24.6165H2.60415C2.05161 24.6165 1.52171 24.397 1.13101 24.0063C0.740306 23.6156 0.520813 23.0857 0.520813 22.5332V2.74154C0.520813 2.189 0.740306 1.6591 1.13101 1.2684C1.52171 0.877697 2.05161 0.658203 2.60415 0.658203H17.1875C17.74 0.658203 18.2699 0.877697 18.6606 1.2684C19.0513 1.6591 19.2708 2.189 19.2708 2.74154V22.5332Z"
|
||||
fill="#00C4B8"
|
||||
/>
|
||||
<path
|
||||
d="M14.0625 2.74219H16.1458C16.4221 2.74219 16.687 2.85193 16.8824 3.04728C17.0777 3.24263 17.1875 3.50759 17.1875 3.78385V21.4922C17.1875 21.7685 17.0777 22.0334 16.8824 22.2288C16.687 22.4241 16.4221 22.5339 16.1458 22.5339H3.64579C3.36953 22.5339 3.10457 22.4241 2.90922 22.2288C2.71387 22.0334 2.60413 21.7685 2.60413 21.4922V3.78385C2.60413 3.50759 2.71387 3.24263 2.90922 3.04728C3.10457 2.85193 3.36953 2.74219 3.64579 2.74219H5.72913"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M20.7129 5.13653L8.9212 16.918L11.1285 19.1272L22.9202 7.34573L20.7129 5.13653Z"
|
||||
fill="#00C4B8"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M24.025 6.24147L22.9167 7.34564L20.7125 5.13731L21.8167 4.03314C21.9611 3.88713 22.1331 3.77122 22.3226 3.69211C22.5121 3.613 22.7155 3.57227 22.9209 3.57227C23.1262 3.57227 23.3296 3.613 23.5191 3.69211C23.7086 3.77122 23.8806 3.88713 24.025 4.03314C24.3154 4.32731 24.4781 4.72399 24.4781 5.13731C24.4781 5.55062 24.3154 5.9473 24.025 6.24147Z"
|
||||
fill="#00C4B8"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.9041 3.0776C16.8079 2.97216 16.6908 2.88789 16.5602 2.83014C16.4297 2.7724 16.2885 2.74245 16.1458 2.74219H3.64579C3.36953 2.74219 3.10457 2.85193 2.90922 3.04728C2.71387 3.24263 2.60413 3.50759 2.60413 3.78385V17.3766L16.9041 3.0776Z"
|
||||
fill="#F8FAFC"
|
||||
/>
|
||||
<path
|
||||
d="M8.92288 16.9199L7.60413 20.4501L11.1343 19.1293L8.92288 16.9199Z"
|
||||
fill="#00C4B8"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.6875 12.1169L5.06875 12.8794C5.10442 12.951 5.15662 13.0131 5.22105 13.0606C5.28548 13.108 5.36027 13.1395 5.43926 13.1523C5.51824 13.1651 5.59914 13.1589 5.67528 13.1343C5.75141 13.1097 5.82058 13.0673 5.87708 13.0106L7.8125 11.0752"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.89581 12.1172H10.9375"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.72913 0.658203H14.0625V2.74154C14.0625 3.0178 13.9527 3.28276 13.7574 3.47811C13.562 3.67346 13.2971 3.7832 13.0208 3.7832H6.77079C6.49453 3.7832 6.22957 3.67346 6.03422 3.47811C5.83887 3.28276 5.72913 3.0178 5.72913 2.74154V0.658203Z"
|
||||
fill="#C4F0EB"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.6875 7.94987L5.06875 8.71237C5.10442 8.784 5.15662 8.84611 5.22105 8.89357C5.28548 8.94103 5.36027 8.97247 5.43926 8.98529C5.51824 8.99812 5.59914 8.99196 5.67528 8.96732C5.75141 8.94269 5.82058 8.90028 5.87708 8.84362L7.8125 6.9082"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.89581 7.9502H14.0625"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.2708 14.1999V22.5332C19.2708 23.0857 19.0513 23.6156 18.6606 24.0063C18.2699 24.397 17.74 24.6165 17.1875 24.6165H2.60415C2.05161 24.6165 1.52171 24.397 1.13101 24.0063C0.740306 23.6156 0.520813 23.0857 0.520813 22.5332V2.74154C0.520813 2.189 0.740306 1.6591 1.13101 1.2684C1.52171 0.877697 2.05161 0.658203 2.60415 0.658203H17.1875C17.74 0.658203 18.2699 0.877697 18.6606 1.2684C19.0513 1.6591 19.2708 2.189 19.2708 2.74154V3.7832"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.1875 16.2839V21.4922C17.1875 21.7685 17.0777 22.0334 16.8824 22.2288C16.687 22.4241 16.4221 22.5339 16.1458 22.5339H3.64579C3.36953 22.5339 3.10457 22.4241 2.90922 22.2288C2.71387 22.0334 2.60413 21.7685 2.60413 21.4922V3.78385C2.60413 3.50759 2.71387 3.24263 2.90922 3.04728C3.10457 2.85193 3.36953 2.74219 3.64579 2.74219H5.72913"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.0625 2.74219H16.1458C16.4221 2.74219 16.6871 2.85193 16.8824 3.04728C17.0778 3.24263 17.1875 3.50759 17.1875 3.78385V5.86719"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="1.04167"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_716">
|
||||
<rect width={25} height={25} fill="white" transform="translate(0 0.137695)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
51
apps/web/modules/ui/components/icons/founder-icon.tsx
Normal file
51
apps/web/modules/ui/components/icons/founder-icon.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
export const FounderIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"swimming-jump"}</title>
|
||||
<path
|
||||
d="M23.5,23.5h-13l0-.043c1.492.318,2.712-1.229,2.712-1.229h0a2.805,2.805,0,0,0,2.2,1.251,3.216,3.216,0,0,0,2.387-1.251h0a2.327,2.327,0,0,0,2.017,1.251,2.682,2.682,0,0,0,2.2-1.251h0a2.983,2.983,0,0,0,1.47,1.159Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M.5,19.5H10a.5.5,0,0,1,.5.5v3.5H.5Z" fill="#c4f0eb" />
|
||||
<circle
|
||||
cx={18}
|
||||
cy={6.15}
|
||||
r={2.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M23.5,23.387a2.983,2.983,0,0,1-1.47-1.159h0a2.682,2.682,0,0,1-2.2,1.251,2.327,2.327,0,0,1-2.017-1.251h0a3.216,3.216,0,0,1-2.387,1.251,2.805,2.805,0,0,1-2.2-1.251h0S12,23.775,10.5,23.457"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M.5,19.5H10a.5.5,0,0,1,.5.5v3.5"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.251,17.5l2.364-3.676L3.062,10.169A1.5,1.5,0,0,1,4,7.5H9.753L6.738,2.811A1.5,1.5,0,1,1,9.26,1.188l4.5,7A1.5,1.5,0,0,1,12.5,10.5H8.266l2.28,1.83a1.5,1.5,0,0,1,.323,1.981L8.818,17.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M13.4,10.2a1.5,1.5,0,0,0,.365-2.013l-4.5-7A1.5,1.5,0,0,0,7.188.738a1.433,1.433,0,0,0-.126.09Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M5.251,17.5l2.364-3.676L3.062,10.169A1.5,1.5,0,0,1,4,7.5H9.753L6.738,2.811A1.5,1.5,0,1,1,9.26,1.188l4.5,7A1.5,1.5,0,0,1,12.5,10.5H8.266l2.28,1.83a1.5,1.5,0,0,1,.323,1.981L8.818,17.5"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
118
apps/web/modules/ui/components/icons/gauge-speed-fast-icon.tsx
Normal file
118
apps/web/modules/ui/components/icons/gauge-speed-fast-icon.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
export const GaugeSpeedFastIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M12,2.5A11.5,11.5,0,0,0,.5,14v3.5a1,1,0,0,0,1,1h21a1,1,0,0,0,1-1V14A11.5,11.5,0,0,0,12,2.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M18.665,4.627A11.5,11.5,0,0,0,.5,14v3.5a1,1,0,0,0,1,1H4.792Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M12,2.5A11.5,11.5,0,0,0,.5,14v3.5a1,1,0,0,0,1,1h21a1,1,0,0,0,1-1V14A11.5,11.5,0,0,0,12,2.5Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={12}
|
||||
cy={14}
|
||||
r={1.5}
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={13.06}
|
||||
y1={12.939}
|
||||
x2={18.011}
|
||||
y2={7.99}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={2.5}
|
||||
y1={14.5}
|
||||
x2={4.5}
|
||||
y2={14.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={21.5}
|
||||
y1={14.5}
|
||||
x2={19.5}
|
||||
y2={14.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={20.776}
|
||||
y1={10.365}
|
||||
x2={18.929}
|
||||
y2={11.13}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={15.635}
|
||||
y1={5.223}
|
||||
x2={14.87}
|
||||
y2={7.071}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={12}
|
||||
y1={4.5}
|
||||
x2={12}
|
||||
y2={6.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={8.365}
|
||||
y1={5.223}
|
||||
x2={9.13}
|
||||
y2={7.071}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={5.283}
|
||||
y1={7.282}
|
||||
x2={6.695}
|
||||
y2={8.697}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={3.224}
|
||||
y1={10.365}
|
||||
x2={5.07}
|
||||
y2={11.13}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
54
apps/web/modules/ui/components/icons/hand-puzzle-icon.tsx
Normal file
54
apps/web/modules/ui/components/icons/hand-puzzle-icon.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
export const HandPuzzleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width="77" height="77" viewBox="0 0 77 77" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g clipPath="url(#clip0_914_610)">
|
||||
<path
|
||||
d="M65.7708 56.3451L51.9269 60.7309C52.5365 58.8347 51.805 56.1462 49.7292 56.1462H38.5C36.8958 56.1462 31.6085 49.7295 24.0625 49.7295H14.4375V67.5742C48.125 78.8034 36.8958 78.8034 75.3958 59.5534C74.3129 58.0976 72.8092 57.0092 71.0879 56.4355C69.3666 55.8617 67.5107 55.8301 65.7708 56.3451Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M12.8334 46.5205H3.20841C2.32246 46.5205 1.60425 47.2387 1.60425 48.1247V70.583C1.60425 71.469 2.32246 72.1872 3.20841 72.1872H12.8334C13.7194 72.1872 14.4376 71.469 14.4376 70.583V48.1247C14.4376 47.2387 13.7194 46.5205 12.8334 46.5205Z"
|
||||
fill="white"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.4375 67.577C48.125 78.8061 36.8958 78.8061 75.3958 59.5561C74.3142 58.0989 72.8105 57.0094 71.0888 56.4355C69.3671 55.8616 67.5105 55.831 65.7708 56.3478L51.9205 60.7336"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.4375 49.7295H24.0625C31.6117 49.7295 36.8958 56.1462 38.5 56.1462H49.7292C52.9375 56.1462 52.9375 62.5628 49.7292 62.5628H32.0833"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M56.3735 36.5746C55.1699 35.371 54.4937 33.7386 54.4937 32.0365C54.4937 30.3343 55.1699 28.7019 56.3735 27.4983C57.5771 26.2947 59.2095 25.6185 60.9117 25.6185C62.6138 25.6185 64.2463 26.2947 65.4499 27.4983C66.0768 28.1203 66.5665 28.8667 66.8872 29.6896C66.9843 29.9381 67.1418 30.1586 67.3455 30.3309C67.5493 30.5032 67.7928 30.622 68.054 30.6765C68.3153 30.731 68.5859 30.7194 68.8416 30.6429C69.0972 30.5663 69.3297 30.4272 69.518 30.2382L74.5263 25.23C75.1277 24.6283 75.4656 23.8124 75.4656 22.9617C75.4656 22.1109 75.1277 21.295 74.5263 20.6934L69.3929 15.5601C70.4792 15.266 71.469 14.6914 72.2631 13.8939C73.0572 13.0964 73.6275 12.1041 73.9169 11.0166C74.2064 9.92901 74.2047 8.78448 73.912 7.6978C73.6194 6.61112 73.0461 5.62052 72.2497 4.8254C71.4532 4.03027 70.4617 3.45859 69.3745 3.16772C68.2874 2.87685 67.1429 2.87703 66.0558 3.16822C64.9687 3.45941 63.9774 4.03139 63.1812 4.82675C62.385 5.62211 61.812 6.61288 61.5197 7.69964L56.3863 2.56631C55.7847 1.96484 54.9688 1.62695 54.118 1.62695C53.2673 1.62695 52.4514 1.96484 51.8498 2.56631L47.1977 7.21518C47.0293 7.38343 46.9005 7.58703 46.8205 7.81122C46.7406 8.03542 46.7116 8.2746 46.7355 8.51141C46.7595 8.74822 46.8358 8.97673 46.9591 9.18038C47.0823 9.38403 47.2493 9.5577 47.4479 9.68881C48.2405 10.2141 48.9064 10.909 49.3975 11.7231C49.8886 12.5373 50.1926 13.4504 50.2875 14.3965C50.3825 15.3425 50.266 16.2979 49.9466 17.1934C49.6271 18.0889 49.1126 18.9023 48.4402 19.5746C47.7679 20.2469 46.9546 20.7615 46.059 21.0809C45.1635 21.4004 44.2082 21.5169 43.2621 21.4219C42.316 21.327 41.4029 21.0229 40.5887 20.5318C39.7746 20.0407 39.0797 19.3748 38.5544 18.5823C38.4233 18.3837 38.2496 18.2167 38.046 18.0934C37.8424 17.9702 37.6138 17.8939 37.377 17.8699C37.1402 17.846 36.901 17.875 36.6768 17.9549C36.4526 18.0349 36.249 18.1637 36.0808 18.3321L31.4191 22.9617C30.8176 23.5633 30.4797 24.3792 30.4797 25.23C30.4797 26.0807 30.8176 26.8966 31.4191 27.4983L49.5686 45.6478C50.1703 46.2493 50.9862 46.5872 51.8369 46.5872C52.6877 46.5872 53.5036 46.2493 54.1052 45.6478L59.1134 40.6364C59.3018 40.448 59.4402 40.2157 59.5163 39.9605C59.5924 39.7052 59.6038 39.435 59.5493 39.1743C59.4949 38.9136 59.3764 38.6705 59.2045 38.467C59.0326 38.2635 58.8128 38.106 58.5648 38.0088C57.7426 37.6882 56.9963 37.1998 56.3735 36.5746Z"
|
||||
fill="#00C4B8"
|
||||
/>
|
||||
<path
|
||||
d="M72.2195 4.7809C71.42 3.99363 70.428 3.42978 69.3426 3.14558C68.2571 2.86139 67.1161 2.86679 66.0334 3.16125C64.9507 3.45571 63.9641 4.02893 63.1721 4.82373C62.3801 5.61853 61.8103 6.60711 61.5197 7.69085L56.3863 2.55752C55.7847 1.95605 54.9688 1.61816 54.118 1.61816C53.2673 1.61816 52.4514 1.95605 51.8498 2.55752L47.1977 7.2064C47.0293 7.37464 46.9005 7.57824 46.8205 7.80243C46.7406 8.02663 46.7116 8.26581 46.7355 8.50262C46.7595 8.73943 46.8358 8.96795 46.9591 9.17159C47.0823 9.37524 47.2493 9.54891 47.4479 9.68002C48.2405 10.2053 48.9064 10.9002 49.3975 11.7143C49.8886 12.5285 50.1926 13.4416 50.2875 14.3877C50.3825 15.3338 50.266 16.2891 49.9466 17.1846C49.6271 18.0802 49.1126 18.8935 48.4402 19.5658C47.7679 20.2382 46.9546 20.7527 46.059 21.0721C45.1635 21.3916 44.2082 21.5081 43.2621 21.4131C42.316 21.3182 41.4029 21.0142 40.5887 20.5231C39.7746 20.032 39.0797 19.3661 38.5544 18.5735C38.4231 18.3751 38.2492 18.2084 38.0454 18.0855C37.8416 17.9625 37.6131 17.8865 37.3763 17.8628C37.1395 17.8392 36.9004 17.8685 36.6763 17.9487C36.4522 18.0289 36.2488 18.1579 36.0808 18.3265L31.4191 22.9625C30.8176 23.5642 30.4797 24.3801 30.4797 25.2308C30.4797 26.0815 30.8176 26.8975 31.4191 27.4991L40.4602 36.5402L72.2195 4.7809Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M56.3735 36.5746C55.1699 35.371 54.4937 33.7386 54.4937 32.0365C54.4937 30.3343 55.1699 28.7019 56.3735 27.4983C57.5771 26.2947 59.2095 25.6185 60.9117 25.6185C62.6138 25.6185 64.2463 26.2947 65.4499 27.4983C66.0768 28.1203 66.5665 28.8667 66.8872 29.6896C66.9843 29.9381 67.1418 30.1586 67.3455 30.3309C67.5493 30.5032 67.7928 30.622 68.054 30.6765C68.3153 30.731 68.5859 30.7194 68.8416 30.6429C69.0972 30.5663 69.3297 30.4272 69.518 30.2382L74.5263 25.23C75.1277 24.6283 75.4656 23.8124 75.4656 22.9617C75.4656 22.1109 75.1277 21.295 74.5263 20.6934L69.3929 15.5601C70.4792 15.266 71.469 14.6914 72.2631 13.8939C73.0572 13.0964 73.6275 12.1041 73.9169 11.0166C74.2064 9.92901 74.2047 8.78448 73.912 7.6978C73.6194 6.61112 73.0461 5.62052 72.2497 4.8254C71.4532 4.03027 70.4617 3.45859 69.3746 3.16772C68.2874 2.87685 67.1429 2.87703 66.0558 3.16822C64.9687 3.45941 63.9774 4.03139 63.1812 4.82675C62.385 5.62211 61.812 6.61288 61.5197 7.69964L56.3863 2.56631C55.7847 1.96484 54.9688 1.62695 54.118 1.62695C53.2673 1.62695 52.4514 1.96484 51.8498 2.56631L47.1977 7.21518C47.0293 7.38343 46.9005 7.58703 46.8205 7.81122C46.7406 8.03542 46.7116 8.2746 46.7355 8.51141C46.7595 8.74822 46.8358 8.97673 46.9591 9.18038C47.0823 9.38403 47.2493 9.5577 47.4479 9.68881C48.2405 10.2141 48.9064 10.909 49.3975 11.7231C49.8886 12.5373 50.1926 13.4504 50.2875 14.3965C50.3825 15.3425 50.266 16.2979 49.9466 17.1934C49.6271 18.0889 49.1126 18.9023 48.4402 19.5746C47.7679 20.2469 46.9546 20.7615 46.059 21.0809C45.1635 21.4004 44.2082 21.5169 43.2621 21.4219C42.316 21.327 41.4029 21.0229 40.5887 20.5318C39.7746 20.0407 39.0797 19.3748 38.5544 18.5823C38.4233 18.3837 38.2496 18.2167 38.046 18.0934C37.8424 17.9702 37.6138 17.8939 37.377 17.8699C37.1402 17.846 36.901 17.875 36.6768 17.9549C36.4526 18.0349 36.249 18.1637 36.0808 18.3321L31.4191 22.9617C30.8176 23.5633 30.4797 24.3792 30.4797 25.23C30.4797 26.0807 30.8176 26.8966 31.4191 27.4983L49.5686 45.6478C50.1703 46.2493 50.9862 46.5872 51.8369 46.5872C52.6877 46.5872 53.5036 46.2493 54.1052 45.6478L59.1134 40.6364C59.3018 40.448 59.4402 40.2157 59.5163 39.9605C59.5924 39.7052 59.6038 39.435 59.5493 39.1743C59.4949 38.9136 59.3764 38.6705 59.2045 38.467C59.0326 38.2635 58.8128 38.106 58.5648 38.0088C57.7426 37.6882 56.9963 37.1998 56.3735 36.5746V36.5746Z"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="3.20833"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_914_610">
|
||||
<rect width="77" height="77" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
37
apps/web/modules/ui/components/icons/heart-comment-icon.tsx
Normal file
37
apps/web/modules/ui/components/icons/heart-comment-icon.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
export const HeartCommentIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M12,1C5.649,1,.5,5.253.5,10.5a8.738,8.738,0,0,0,3.4,6.741L1.5,23l6.372-3.641A13.608,13.608,0,0,0,12,20c6.351,0,11.5-4.253,11.5-9.5S18.351,1,12,1Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M12,5a14.371,14.371,0,0,1,11.5,5.265C23.346,5.127,18.256,1,12,1S.654,5.127.5,10.265A14.371,14.371,0,0,1,12,5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M12,1C5.649,1,.5,5.253.5,10.5a8.738,8.738,0,0,0,3.4,6.741L1.5,23l6.372-3.641A13.608,13.608,0,0,0,12,20c6.351,0,11.5-4.253,11.5-9.5S18.351,1,12,1Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12,15,7.861,10.682A2.447,2.447,0,0,1,7.4,7.855h0a2.449,2.449,0,0,1,3.922-.637L12,7.894l.676-.676a2.449,2.449,0,0,1,3.922.637h0a2.449,2.449,0,0,1-.458,2.827Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M16.6,7.855a2.449,2.449,0,0,0-3.922-.637L12,7.894l-.676-.676a2.443,2.443,0,0,0-4.1,2.342,6.493,6.493,0,0,0,9.611-.389A2.429,2.429,0,0,0,16.6,7.855Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M12,15,7.861,10.682A2.447,2.447,0,0,1,7.4,7.855h0a2.449,2.449,0,0,1,3.922-.637L12,7.894l.676-.676a2.449,2.449,0,0,1,3.922.637h0a2.449,2.449,0,0,1-.458,2.827Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user