mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dcaaa87d8 | |||
| 7971b9b312 | |||
| 1143f58ba5 | |||
| 47fe3c73dd | |||
| 727e586b16 | |||
| 4a9b4d52ca | |||
| cbb0166419 | |||
| 4b0c518683 | |||
| 5f05f8d36b | |||
| f7558a7497 | |||
| 009beba866 | |||
| c3ec5ddc3a | |||
| 9573ae19e6 | |||
| 7b3f841c5e |
@@ -1,20 +1,4 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
||||
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["locales/*.json"],
|
||||
plugins: ["i18n-json"],
|
||||
rules: {
|
||||
"i18n-json/identical-keys": [
|
||||
"error",
|
||||
{
|
||||
filePath: require("path").join(__dirname, "locales", "en-US.json"),
|
||||
checkExtraKeys: false,
|
||||
checkMissingKeys: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+10
-18
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine3.22 AS base
|
||||
FROM node:24-alpine3.23 AS base
|
||||
|
||||
#
|
||||
## step 1: Prune monorepo
|
||||
@@ -20,7 +20,7 @@ FROM base AS installer
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.15.9 --activate
|
||||
RUN corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
@@ -69,20 +69,14 @@ RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||
|
||||
#
|
||||
## step 3: setup production runner
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||
corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
# && addgroup --system --gid 1001 nodejs \
|
||||
# Update npm to latest, then create user
|
||||
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
||||
RUN npm install --ignore-scripts -g npm@latest \
|
||||
&& addgroup -S nextjs \
|
||||
&& adduser -S -u 1001 -G nextjs nextjs
|
||||
|
||||
@@ -113,15 +107,13 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
|
||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
|
||||
# Copy prisma client packages
|
||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||
|
||||
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
||||
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
||||
|
||||
COPY --from=installer /prisma_version.txt .
|
||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
@@ -134,7 +126,9 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g prisma@6
|
||||
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
|
||||
RUN npm install --ignore-scripts -g prisma@6 \
|
||||
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
@@ -144,10 +138,8 @@ EXPOSE 3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||
# Prepare volumes for uploads and SAML connections
|
||||
RUN corepack prepare pnpm@9.15.9 --activate && \
|
||||
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
@@ -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 } = license;
|
||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
@@ -63,6 +63,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BarChartIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
RocketIcon,
|
||||
ShapesIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -101,7 +99,7 @@ export const MainNavigation = ({
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Ask",
|
||||
name: t("common.surveys"),
|
||||
href: `/environments/${environment.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
@@ -109,24 +107,12 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
name: "Distribute",
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||
},
|
||||
{
|
||||
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",
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/project"),
|
||||
@@ -199,7 +185,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:outline-none focus:ring-0 focus:ring-transparent"
|
||||
"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"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -133,11 +133,6 @@ export const ProjectBreadcrumb = ({
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||
},
|
||||
{
|
||||
id: "unify",
|
||||
label: "Unify Feedback",
|
||||
href: `/environments/${currentEnvironmentId}/workspace/unify`,
|
||||
},
|
||||
];
|
||||
|
||||
if (!currentProject) {
|
||||
|
||||
+27
-18
@@ -316,6 +316,14 @@ export const generateResponseTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const responseIdColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "responseId",
|
||||
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
|
||||
cell: ({ row }) => {
|
||||
return <IdBadge id={row.original.responseId} />;
|
||||
},
|
||||
};
|
||||
|
||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "quota",
|
||||
header: t("common.quota"),
|
||||
@@ -376,24 +384,24 @@ export const generateResponseTableColumns = (
|
||||
|
||||
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
||||
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
||||
return {
|
||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate">{hiddenFieldId}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
|
||||
if (typeof hiddenFieldResponse === "string") {
|
||||
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
|
||||
}
|
||||
},
|
||||
};
|
||||
})
|
||||
return {
|
||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate">{hiddenFieldId}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
|
||||
if (typeof hiddenFieldResponse === "string") {
|
||||
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
|
||||
}
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const metadataColumns = getMetadataColumnsData(t);
|
||||
@@ -414,6 +422,7 @@ export const generateResponseTableColumns = (
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
singleUseIdColumn,
|
||||
responseIdColumn,
|
||||
dateColumn,
|
||||
...(showQuotasColumn ? [quotasColumn] : []),
|
||||
statusColumn,
|
||||
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { CreateDashboardModal } from "./create-dashboard-modal";
|
||||
import { DashboardsTable } from "./dashboards-table";
|
||||
import { TDashboard } from "./types";
|
||||
|
||||
interface AnalyzeSectionProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function AnalyzeSection({ environmentId }: AnalyzeSectionProps) {
|
||||
const [dashboards, setDashboards] = useState<TDashboard[]>([]);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const handleCreateDashboard = (dashboard: TDashboard) => {
|
||||
setDashboards((prev) => [dashboard, ...prev]);
|
||||
};
|
||||
|
||||
const handleDashboardClick = (dashboard: TDashboard) => {
|
||||
// TODO: Navigate to dashboard detail view
|
||||
console.log("Dashboard clicked:", dashboard);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle="Analyze"
|
||||
cta={
|
||||
<CreateDashboardModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateDashboard={handleCreateDashboard}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<DashboardsTable dashboards={dashboards} onDashboardClick={handleDashboardClick} />
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TDashboard } from "./types";
|
||||
|
||||
interface CreateDashboardModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateDashboard: (dashboard: TDashboard) => void;
|
||||
}
|
||||
|
||||
export function CreateDashboardModal({ open, onOpenChange, onCreateDashboard }: CreateDashboardModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const newDashboard: TDashboard = {
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
widgetCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
onCreateDashboard(newDashboard);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
Create dashboard
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Dashboard</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new dashboard to visualize and analyze your unified feedback data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dashboardName">Name</Label>
|
||||
<Input
|
||||
id="dashboardName"
|
||||
placeholder="e.g., Product Feedback Overview"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dashboardDescription">Description (optional)</Label>
|
||||
<Input
|
||||
id="dashboardDescription"
|
||||
placeholder="e.g., Weekly overview of customer feedback trends"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim()}>
|
||||
Create dashboard
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { AnalyzeSection } from "./AnalyzeSection";
|
||||
export { CreateDashboardModal } from "./create-dashboard-modal";
|
||||
export { DashboardsTable } from "./dashboards-table";
|
||||
export type { TDashboard } from "./types";
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface TDashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
widgetCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { AnalyzeSection } from "./components";
|
||||
|
||||
export default async function AnalyzePage(props: { params: Promise<{ environmentId: string }> }) {
|
||||
const params = await props.params;
|
||||
|
||||
await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
return <AnalyzeSection environmentId={params.environmentId} />;
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
environmentId: string;
|
||||
activeId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const UnifyConfigNavigation = ({
|
||||
environmentId,
|
||||
activeId: activeIdProp,
|
||||
loading,
|
||||
}: UnifyConfigNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeId =
|
||||
activeIdProp ??
|
||||
(pathname?.includes("/unify/sources")
|
||||
? "sources"
|
||||
: pathname?.includes("/unify/knowledge")
|
||||
? "knowledge"
|
||||
: pathname?.includes("/unify/taxonomy")
|
||||
? "taxonomy"
|
||||
: "controls");
|
||||
|
||||
const baseHref = `/environments/${environmentId}/workspace/unify`;
|
||||
|
||||
const navigation = [
|
||||
{ id: "controls", label: "Controls", href: `${baseHref}/controls` },
|
||||
{ id: "sources", label: "Sources", href: `${baseHref}/sources` },
|
||||
{ id: "knowledge", label: "Knowledge", href: `${baseHref}/knowledge` },
|
||||
{ id: "taxonomy", label: "Taxonomy", href: `${baseHref}/taxonomy` },
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
};
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
|
||||
// Common languages for the base language selector
|
||||
const COMMON_LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "de", label: "German" },
|
||||
{ code: "fr", label: "French" },
|
||||
{ code: "es", label: "Spanish" },
|
||||
{ code: "pt", label: "Portuguese" },
|
||||
{ code: "it", label: "Italian" },
|
||||
{ code: "nl", label: "Dutch" },
|
||||
{ code: "pl", label: "Polish" },
|
||||
{ code: "ru", label: "Russian" },
|
||||
{ code: "ja", label: "Japanese" },
|
||||
{ code: "ko", label: "Korean" },
|
||||
{ code: "zh-Hans", label: "Chinese (Simplified)" },
|
||||
{ code: "zh-Hant", label: "Chinese (Traditional)" },
|
||||
{ code: "ar", label: "Arabic" },
|
||||
{ code: "hi", label: "Hindi" },
|
||||
{ code: "tr", label: "Turkish" },
|
||||
{ code: "sv", label: "Swedish" },
|
||||
{ code: "no", label: "Norwegian" },
|
||||
{ code: "da", label: "Danish" },
|
||||
{ code: "fi", label: "Finnish" },
|
||||
];
|
||||
|
||||
interface ControlsSectionProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function ControlsSection({ environmentId }: ControlsSectionProps) {
|
||||
const [baseLanguage, setBaseLanguage] = useState("en");
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Unify Feedback">
|
||||
<UnifyConfigNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
|
||||
<div className="max-w-4xl">
|
||||
<SettingsCard
|
||||
title="Feedback Controls"
|
||||
description="Configure how feedback is processed and consolidated across all sources.">
|
||||
<div className="space-y-6">
|
||||
{/* Base Language Setting */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="baseLanguage">Base Language</Label>
|
||||
<Badge text="AI" type="gray" size="tiny" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
All feedback will be consolidated and analyzed in this language. Feedback in other languages
|
||||
will be automatically translated.
|
||||
</p>
|
||||
<div className="w-64">
|
||||
<Select value={baseLanguage} onValueChange={setBaseLanguage}>
|
||||
<SelectTrigger id="baseLanguage">
|
||||
<SelectValue placeholder="Select a language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMON_LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { ControlsSection } from "./ControlsSection";
|
||||
@@ -1,10 +0,0 @@
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { ControlsSection } from "./components";
|
||||
|
||||
export default async function UnifyControlsPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||
const params = await props.params;
|
||||
|
||||
await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
return <ControlsSection environmentId={params.environmentId} />;
|
||||
}
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FileTextIcon, LinkIcon, PlusIcon, StickyNoteIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import type { KnowledgeItem } from "../types";
|
||||
|
||||
const DOC_EXTENSIONS: TAllowedFileExtension[] = ["pdf", "doc", "docx", "txt", "csv"];
|
||||
const MAX_DOC_SIZE_MB = 5;
|
||||
|
||||
interface AddKnowledgeModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (item: KnowledgeItem) => void;
|
||||
environmentId: string;
|
||||
isStorageConfigured: boolean;
|
||||
}
|
||||
|
||||
export function AddKnowledgeModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
environmentId,
|
||||
isStorageConfigured,
|
||||
}: AddKnowledgeModalProps) {
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [linkTitle, setLinkTitle] = useState("");
|
||||
const [noteContent, setNoteContent] = useState("");
|
||||
const [uploadedDocUrl, setUploadedDocUrl] = useState<string | null>(null);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setLinkUrl("");
|
||||
setLinkTitle("");
|
||||
setNoteContent("");
|
||||
setUploadedDocUrl(null);
|
||||
setUploadedFileName(null);
|
||||
};
|
||||
|
||||
const handleDocUpload = async (files: File[]) => {
|
||||
if (!isStorageConfigured) {
|
||||
toast.error("File storage is not configured.");
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
setUploadedDocUrl(null);
|
||||
setUploadedFileName(null);
|
||||
const result = await handleFileUpload(file, environmentId, DOC_EXTENSIONS);
|
||||
setIsUploading(false);
|
||||
if (result.error) {
|
||||
toast.error("Upload failed. Please try again.");
|
||||
return;
|
||||
}
|
||||
setUploadedDocUrl(result.url);
|
||||
setUploadedFileName(file.name);
|
||||
toast.success("Document uploaded. Click Add to save.");
|
||||
};
|
||||
|
||||
const handleAddLink = () => {
|
||||
if (!linkUrl.trim()) {
|
||||
toast.error("Please enter a URL.");
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
onAdd({
|
||||
id: crypto.randomUUID(),
|
||||
type: "link",
|
||||
title: linkTitle.trim() || undefined,
|
||||
url: linkUrl.trim(),
|
||||
size: linkUrl.trim().length * 100, // Simulated size for links
|
||||
createdAt: now,
|
||||
indexedAt: now, // Links are indexed immediately
|
||||
});
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
toast.success("Link added.");
|
||||
};
|
||||
|
||||
const handleAddNote = () => {
|
||||
if (!noteContent.trim()) {
|
||||
toast.error("Please enter some text.");
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
onAdd({
|
||||
id: crypto.randomUUID(),
|
||||
type: "note",
|
||||
content: noteContent.trim(),
|
||||
size: new Blob([noteContent.trim()]).size,
|
||||
createdAt: now,
|
||||
indexedAt: now, // Notes are indexed immediately
|
||||
});
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
toast.success("Note added.");
|
||||
};
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (!uploadedDocUrl) {
|
||||
toast.error("Please upload a document first.");
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
onAdd({
|
||||
id: crypto.randomUUID(),
|
||||
type: "file",
|
||||
title: uploadedFileName ?? undefined,
|
||||
fileUrl: uploadedDocUrl,
|
||||
fileName: uploadedFileName ?? undefined,
|
||||
size: Math.floor(Math.random() * 500000) + 10000, // Simulated file size (10KB - 500KB)
|
||||
createdAt: now,
|
||||
indexedAt: undefined, // Files take time to index - will show as "Pending"
|
||||
});
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
toast.success("Document added.");
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) handleDocUpload(files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
Add knowledge
|
||||
<PlusIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) resetForm();
|
||||
onOpenChange(o);
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-lg" disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<PlusIcon className="size-5 text-slate-600" />
|
||||
<DialogTitle>Add knowledge</DialogTitle>
|
||||
<DialogDescription>Add knowledge via a link, document upload, or a text note.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<Tabs defaultValue="link" className="w-full">
|
||||
<TabsList width="fill" className="mb-4 w-full">
|
||||
<TabsTrigger value="link" icon={<LinkIcon className="size-4" />}>
|
||||
Link
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upload" icon={<FileTextIcon className="size-4" />}>
|
||||
Upload doc
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="note" icon={<StickyNoteIcon className="size-4" />}>
|
||||
Note
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="link" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-title">Title (optional)</Label>
|
||||
<Input
|
||||
id="link-title"
|
||||
placeholder="e.g. Product docs"
|
||||
value={linkTitle}
|
||||
onChange={(e) => setLinkTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-url">URL</Label>
|
||||
<Input
|
||||
id="link-url"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={handleAddLink} size="sm">
|
||||
Add link
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<Uploader
|
||||
id="knowledge-doc-modal"
|
||||
name="knowledge-doc-modal"
|
||||
uploaderClassName="h-32 w-full"
|
||||
allowedFileExtensions={DOC_EXTENSIONS}
|
||||
multiple={false}
|
||||
handleUpload={handleDocUpload}
|
||||
handleDrop={handleDrop}
|
||||
handleDragOver={handleDragOver}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">PDF, Word, text, or CSV. Max {MAX_DOC_SIZE_MB} MB.</p>
|
||||
{isUploading && <p className="text-sm text-slate-600">Uploading…</p>}
|
||||
{uploadedDocUrl && (
|
||||
<p className="text-sm text-slate-700">
|
||||
Ready: <span className="font-medium">{uploadedFileName ?? uploadedDocUrl}</span>
|
||||
</p>
|
||||
)}
|
||||
<Button type="button" onClick={handleAddFile} size="sm" disabled={!uploadedDocUrl}>
|
||||
Add document
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="note" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="knowledge-note-modal">Note</Label>
|
||||
<textarea
|
||||
id="knowledge-note-modal"
|
||||
rows={5}
|
||||
placeholder="Paste or type knowledge content here..."
|
||||
className={cn(
|
||||
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
value={noteContent}
|
||||
onChange={(e) => setNoteContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={handleAddNote} size="sm">
|
||||
Add note
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import type { KnowledgeItem } from "../types";
|
||||
import { AddKnowledgeModal } from "./AddKnowledgeModal";
|
||||
import { KnowledgeTable } from "./KnowledgeTable";
|
||||
|
||||
interface KnowledgeSectionProps {
|
||||
environmentId: string;
|
||||
isStorageConfigured: boolean;
|
||||
}
|
||||
|
||||
export function KnowledgeSection({ environmentId, isStorageConfigured }: KnowledgeSectionProps) {
|
||||
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const handleDeleteItem = (itemId: string) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== itemId));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle="Unify Feedback"
|
||||
cta={
|
||||
<AddKnowledgeModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
onAdd={(item) => {
|
||||
setItems((prev) => [...prev, item]);
|
||||
setModalOpen(false);
|
||||
}}
|
||||
environmentId={environmentId}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
<div className="space-y-6">
|
||||
<KnowledgeTable items={items} onDeleteItem={handleDeleteItem} />
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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`;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/environments/${params.environmentId}/workspace/unify/controls`);
|
||||
}
|
||||
-396
@@ -1,396 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { CsvSourceUI } from "./csv-source-ui";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
import { SourceTypeSelector } from "./source-type-selector";
|
||||
import {
|
||||
AI_SUGGESTED_MAPPINGS,
|
||||
EMAIL_SOURCE_FIELDS,
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
MOCK_FORMBRICKS_SURVEYS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
SAMPLE_WEBHOOK_PAYLOAD,
|
||||
TCreateSourceStep,
|
||||
TFieldMapping,
|
||||
TSourceConnection,
|
||||
TSourceField,
|
||||
TSourceType,
|
||||
parseCSVColumnsToFields,
|
||||
parsePayloadToFields,
|
||||
} from "./types";
|
||||
|
||||
interface CreateSourceModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateSource: (source: TSourceConnection) => void;
|
||||
}
|
||||
|
||||
function getDefaultSourceName(type: TSourceType): string {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks Survey Connection";
|
||||
case "webhook":
|
||||
return "Webhook Connection";
|
||||
case "email":
|
||||
return "Email Connection";
|
||||
case "csv":
|
||||
return "CSV Import";
|
||||
case "slack":
|
||||
return "Slack Connection";
|
||||
default:
|
||||
return "New Source";
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateSourceModal({ open, onOpenChange, onCreateSource }: CreateSourceModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<TCreateSourceStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TSourceType | null>(null);
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||
|
||||
// Formbricks-specific state
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [selectedQuestionIds, setSelectedQuestionIds] = useState<string[]>([]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
setSourceName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setDeriveFromAttachments(false);
|
||||
setSelectedSurveyId(null);
|
||||
setSelectedQuestionIds([]);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep === "selectType" && selectedType && selectedType !== "slack") {
|
||||
if (selectedType === "formbricks") {
|
||||
// For Formbricks, use the survey name if selected
|
||||
const selectedSurvey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === selectedSurveyId);
|
||||
setSourceName(
|
||||
selectedSurvey ? `${selectedSurvey.name} Connection` : getDefaultSourceName(selectedType)
|
||||
);
|
||||
} else {
|
||||
setSourceName(getDefaultSourceName(selectedType));
|
||||
}
|
||||
setCurrentStep("mapping");
|
||||
}
|
||||
};
|
||||
|
||||
// Formbricks handlers
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleQuestionToggle = (questionId: string) => {
|
||||
setSelectedQuestionIds((prev) =>
|
||||
prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllQuestions = (surveyId: string) => {
|
||||
const survey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setSelectedQuestionIds(survey.questions.map((q) => q.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllQuestions = () => {
|
||||
setSelectedQuestionIds([]);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === "mapping") {
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSource = () => {
|
||||
if (!selectedType || !sourceName.trim()) return;
|
||||
|
||||
// Check if all required fields are mapped
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id)
|
||||
);
|
||||
|
||||
if (!allRequiredMapped) {
|
||||
// For now, we'll allow creating without all required fields for POC
|
||||
console.warn("Not all required fields are mapped");
|
||||
}
|
||||
|
||||
const newSource: TSourceConnection = {
|
||||
id: crypto.randomUUID(),
|
||||
name: sourceName.trim(),
|
||||
type: selectedType,
|
||||
mappings,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
onCreateSource(newSource);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||
);
|
||||
|
||||
// Formbricks validation - need survey and at least one question selected
|
||||
const isFormbricksValid =
|
||||
selectedType === "formbricks" && selectedSurveyId && selectedQuestionIds.length > 0;
|
||||
|
||||
// CSV validation - need sourceFields loaded (CSV uploaded or sample loaded)
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (!selectedType) return;
|
||||
let fields: TSourceField[];
|
||||
if (selectedType === "webhook") {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
} else if (selectedType === "email") {
|
||||
fields = EMAIL_SOURCE_FIELDS;
|
||||
} else if (selectedType === "csv") {
|
||||
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
} else {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
}
|
||||
setSourceFields(fields);
|
||||
};
|
||||
|
||||
const handleSuggestMapping = () => {
|
||||
if (!selectedType) return;
|
||||
const suggestions = AI_SUGGESTED_MAPPINGS[selectedType];
|
||||
if (!suggestions) return;
|
||||
|
||||
const newMappings: TFieldMapping[] = [];
|
||||
|
||||
// Add field mappings from source fields
|
||||
for (const sourceField of sourceFields) {
|
||||
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
|
||||
if (suggestedTarget) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
|
||||
if (targetExists) {
|
||||
newMappings.push({
|
||||
sourceFieldId: sourceField.id,
|
||||
targetFieldId: suggestedTarget,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add static value mappings
|
||||
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
|
||||
if (targetExists) {
|
||||
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
|
||||
newMappings.push({
|
||||
targetFieldId,
|
||||
staticValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const getLoadButtonLabel = () => {
|
||||
switch (selectedType) {
|
||||
case "webhook":
|
||||
return "Simulate webhook";
|
||||
case "email":
|
||||
return "Load email fields";
|
||||
case "csv":
|
||||
return "Load sample CSV";
|
||||
default:
|
||||
return "Load sample";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
Add source
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{currentStep === "selectType"
|
||||
? "Add Feedback Source"
|
||||
: selectedType === "formbricks"
|
||||
? "Select Survey & Questions"
|
||||
: selectedType === "csv"
|
||||
? "Import CSV Data"
|
||||
: "Configure Mapping"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentStep === "selectType"
|
||||
? "Select the type of feedback source you want to connect."
|
||||
: selectedType === "formbricks"
|
||||
? "Choose which survey questions should create FeedbackRecords."
|
||||
: selectedType === "csv"
|
||||
? "Upload a CSV file or set up automated S3 imports."
|
||||
: "Map source fields to Hub Feedback Record fields."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{currentStep === "selectType" ? (
|
||||
<SourceTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
) : selectedType === "formbricks" ? (
|
||||
/* Formbricks Survey Selector UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
selectedSurveyId={selectedSurveyId}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onSurveySelect={handleSurveySelect}
|
||||
onQuestionToggle={handleQuestionToggle}
|
||||
onSelectAllQuestions={handleSelectAllQuestions}
|
||||
onDeselectAllQuestions={handleDeselectAllQuestions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedType === "csv" ? (
|
||||
/* CSV Upload & S3 Integration UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<CsvSourceUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
onSourceFieldsChange={setSourceFields}
|
||||
onLoadSampleCSV={handleLoadSourceFields}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Other source types (webhook, email) - Mapping UI */
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourceName">Source Name</Label>
|
||||
<Input
|
||||
id="sourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons above scrollable area */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||
{getLoadButtonLabel()}
|
||||
</Button>
|
||||
{sourceFields.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={selectedType!}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType || selectedType === "slack"}>
|
||||
{selectedType === "formbricks"
|
||||
? "Select questions"
|
||||
: selectedType === "csv"
|
||||
? "Configure import"
|
||||
: "Create mapping"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleCreateSource}
|
||||
disabled={
|
||||
!sourceName.trim() ||
|
||||
(selectedType === "formbricks"
|
||||
? !isFormbricksValid
|
||||
: selectedType === "csv"
|
||||
? !isCsvValid
|
||||
: !allRequiredMapped)
|
||||
}>
|
||||
Setup connection
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CloudIcon,
|
||||
CopyIcon,
|
||||
FolderIcon,
|
||||
RefreshCwIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
import { TFieldMapping, TSourceField } from "./types";
|
||||
|
||||
interface CsvSourceUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
onSourceFieldsChange: (fields: TSourceField[]) => void;
|
||||
onLoadSampleCSV: () => void;
|
||||
}
|
||||
|
||||
export function CsvSourceUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
onSourceFieldsChange,
|
||||
onLoadSampleCSV,
|
||||
}: CsvSourceUIProps) {
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
|
||||
const [showMapping, setShowMapping] = useState(false);
|
||||
const [s3AutoSync, setS3AutoSync] = useState(false);
|
||||
const [s3Copied, setS3Copied] = useState(false);
|
||||
|
||||
// Mock S3 bucket details
|
||||
const s3BucketName = "formbricks-feedback-imports";
|
||||
const s3Path = `s3://${s3BucketName}/feedback/incoming/`;
|
||||
|
||||
const handleCopyS3Path = () => {
|
||||
navigator.clipboard.writeText(s3Path);
|
||||
setS3Copied(true);
|
||||
setTimeout(() => setS3Copied(false), 2000);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const csv = e.target?.result as string;
|
||||
const lines = csv.split("\n").slice(0, 6); // Preview first 5 rows + header
|
||||
const preview = lines.map((line) => line.split(",").map((cell) => cell.trim()));
|
||||
setCsvPreview(preview);
|
||||
|
||||
// Extract columns and create source fields
|
||||
if (preview.length > 0) {
|
||||
const headers = preview[0];
|
||||
const fields: TSourceField[] = headers.map((header) => ({
|
||||
id: header,
|
||||
name: header,
|
||||
type: "string",
|
||||
sampleValue: preview[1]?.[headers.indexOf(header)] || "",
|
||||
}));
|
||||
onSourceFieldsChange(fields);
|
||||
setShowMapping(true);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSample = () => {
|
||||
onLoadSampleCSV();
|
||||
setShowMapping(true);
|
||||
};
|
||||
|
||||
// If mapping is shown, show the mapping UI
|
||||
if (showMapping && sourceFields.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File info bar */}
|
||||
{csvFile && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">{csvFile.name}</span>
|
||||
<Badge text={`${csvPreview.length - 1} rows`} type="success" size="tiny" />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCsvFile(null);
|
||||
setCsvPreview([]);
|
||||
setShowMapping(false);
|
||||
onSourceFieldsChange([]);
|
||||
}}>
|
||||
Change file
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSV Preview Table */}
|
||||
{csvPreview.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{csvPreview[0]?.map((header, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.slice(1, 4).map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t border-slate-100">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="px-3 py-2 text-slate-600">
|
||||
{cell || <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{csvPreview.length > 4 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||
Showing 3 of {csvPreview.length - 1} rows
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mapping UI */}
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={onMappingsChange}
|
||||
sourceType="csv"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Upload and S3 setup UI
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Manual Upload Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Upload CSV File</h4>
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-file-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}>
|
||||
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">CSV files only</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-file-upload"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
|
||||
Load sample CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
<span className="text-xs font-medium uppercase text-slate-400">or</span>
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
|
||||
{/* S3 Integration Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudIcon className="h-5 w-5 text-slate-500" />
|
||||
<h4 className="text-sm font-medium text-slate-700">S3 Bucket Integration</h4>
|
||||
<Badge text="Automated" type="gray" size="tiny" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<p className="mb-4 text-sm text-slate-600">
|
||||
Drop CSV files into your S3 bucket to automatically import feedback. Files are processed every 15
|
||||
minutes.
|
||||
</p>
|
||||
|
||||
{/* S3 Path Display */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Drop zone path</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded bg-slate-100 px-3 py-2 font-mono text-sm text-slate-700">
|
||||
{s3Path}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyS3Path}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{s3Copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* S3 Settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">AWS Region</Label>
|
||||
<Select defaultValue="eu-central-1">
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
|
||||
<SelectItem value="us-west-2">US West (Oregon)</SelectItem>
|
||||
<SelectItem value="eu-central-1">EU (Frankfurt)</SelectItem>
|
||||
<SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
|
||||
<SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Processing interval</Label>
|
||||
<Select defaultValue="15">
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">Every 5 minutes</SelectItem>
|
||||
<SelectItem value="15">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="30">Every 30 minutes</SelectItem>
|
||||
<SelectItem value="60">Every hour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-slate-900">Enable auto-sync</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Automatically process new files dropped in the bucket
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={s3AutoSync} onCheckedChange={setS3AutoSync} />
|
||||
</div>
|
||||
|
||||
{/* IAM Instructions */}
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<SettingsIcon className="mt-0.5 h-4 w-4 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800">IAM Configuration Required</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
Add the Formbricks IAM role to your S3 bucket policy to enable access.{" "}
|
||||
<button type="button" className="font-medium underline hover:no-underline">
|
||||
View setup guide →
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
Test connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-299
@@ -1,299 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FileSpreadsheetIcon,
|
||||
GlobeIcon,
|
||||
MailIcon,
|
||||
MessageSquareIcon,
|
||||
SparklesIcon,
|
||||
WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
import {
|
||||
AI_SUGGESTED_MAPPINGS,
|
||||
EMAIL_SOURCE_FIELDS,
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
SAMPLE_WEBHOOK_PAYLOAD,
|
||||
TFieldMapping,
|
||||
TSourceConnection,
|
||||
TSourceField,
|
||||
TSourceType,
|
||||
parseCSVColumnsToFields,
|
||||
parsePayloadToFields,
|
||||
} from "./types";
|
||||
|
||||
interface EditSourceModalProps {
|
||||
source: TSourceConnection | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdateSource: (source: TSourceConnection) => void;
|
||||
onDeleteSource: (sourceId: string) => void;
|
||||
}
|
||||
|
||||
function getSourceIcon(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "webhook":
|
||||
return <WebhookIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "email":
|
||||
return <MailIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "slack":
|
||||
return <MessageSquareIcon className="h-5 w-5 text-slate-500" />;
|
||||
default:
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceTypeLabel(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks Surveys";
|
||||
case "webhook":
|
||||
return "Webhook";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "csv":
|
||||
return "CSV Import";
|
||||
case "slack":
|
||||
return "Slack Message";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialSourceFields(type: TSourceType): TSourceField[] {
|
||||
switch (type) {
|
||||
case "webhook":
|
||||
return parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
case "email":
|
||||
return EMAIL_SOURCE_FIELDS;
|
||||
case "csv":
|
||||
return parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function EditSourceModal({
|
||||
source,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdateSource,
|
||||
onDeleteSource,
|
||||
}: EditSourceModalProps) {
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deriveFromAttachments, setDeriveFromAttachments] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (source) {
|
||||
setSourceName(source.name);
|
||||
setMappings(source.mappings);
|
||||
setSourceFields(getInitialSourceFields(source.type));
|
||||
setDeriveFromAttachments(false);
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSourceName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setShowDeleteConfirm(false);
|
||||
setDeriveFromAttachments(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleUpdateSource = () => {
|
||||
if (!source || !sourceName.trim()) return;
|
||||
|
||||
const updatedSource: TSourceConnection = {
|
||||
...source,
|
||||
name: sourceName.trim(),
|
||||
mappings,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
onUpdateSource(updatedSource);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteSource = () => {
|
||||
if (!source) return;
|
||||
onDeleteSource(source.id);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (!source) return;
|
||||
let fields: TSourceField[];
|
||||
if (source.type === "webhook") {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
} else if (source.type === "email") {
|
||||
fields = EMAIL_SOURCE_FIELDS;
|
||||
} else if (source.type === "csv") {
|
||||
fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
} else {
|
||||
fields = parsePayloadToFields(SAMPLE_WEBHOOK_PAYLOAD);
|
||||
}
|
||||
setSourceFields(fields);
|
||||
};
|
||||
|
||||
const handleSuggestMapping = () => {
|
||||
if (!source) return;
|
||||
const suggestions = AI_SUGGESTED_MAPPINGS[source.type];
|
||||
if (!suggestions) return;
|
||||
|
||||
const newMappings: TFieldMapping[] = [];
|
||||
|
||||
for (const sourceField of sourceFields) {
|
||||
const suggestedTarget = suggestions.fieldMappings[sourceField.id];
|
||||
if (suggestedTarget) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === suggestedTarget);
|
||||
if (targetExists) {
|
||||
newMappings.push({
|
||||
sourceFieldId: sourceField.id,
|
||||
targetFieldId: suggestedTarget,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [targetFieldId, staticValue] of Object.entries(suggestions.staticValues)) {
|
||||
const targetExists = FEEDBACK_RECORD_FIELDS.find((f) => f.id === targetFieldId);
|
||||
if (targetExists) {
|
||||
if (!newMappings.some((m) => m.targetFieldId === targetFieldId)) {
|
||||
newMappings.push({
|
||||
targetFieldId,
|
||||
staticValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const getLoadButtonLabel = () => {
|
||||
switch (source?.type) {
|
||||
case "webhook":
|
||||
return "Simulate webhook";
|
||||
case "email":
|
||||
return "Load email fields";
|
||||
case "csv":
|
||||
return "Load sample CSV";
|
||||
default:
|
||||
return "Load sample";
|
||||
}
|
||||
};
|
||||
|
||||
if (!source) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Source Connection</DialogTitle>
|
||||
<DialogDescription>Update the mapping configuration for this source.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Source Type Display */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getSourceIcon(source.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{getSourceTypeLabel(source.type)}</p>
|
||||
<p className="text-xs text-slate-500">Source type cannot be changed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editSourceName">Source Name</Label>
|
||||
<Input
|
||||
id="editSourceName"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
placeholder="Enter a name for this source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons above scrollable area */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLoadSourceFields}>
|
||||
{getLoadButtonLabel()}
|
||||
</Button>
|
||||
{sourceFields.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSuggestMapping} className="gap-2">
|
||||
<SparklesIcon className="h-4 w-4 text-purple-500" />
|
||||
Suggest mapping
|
||||
<Badge text="AI" type="gray" size="tiny" className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mapping UI */}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
sourceType={source.type}
|
||||
deriveFromAttachments={deriveFromAttachments}
|
||||
onDeriveFromAttachmentsChange={setDeriveFromAttachments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<div>
|
||||
{showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600">Are you sure?</span>
|
||||
<Button variant="destructive" size="sm" onClick={handleDeleteSource}>
|
||||
Yes, delete
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setShowDeleteConfirm(true)}>
|
||||
Delete source
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleUpdateSource} disabled={!sourceName.trim()}>
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CircleIcon,
|
||||
FileTextIcon,
|
||||
MessageSquareTextIcon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import {
|
||||
MOCK_FORMBRICKS_SURVEYS,
|
||||
TFormbricksSurvey,
|
||||
TFormbricksSurveyQuestion,
|
||||
getQuestionTypeLabel,
|
||||
} from "./types";
|
||||
|
||||
interface FormbricksSurveySelectorProps {
|
||||
selectedSurveyId: string | null;
|
||||
selectedQuestionIds: string[];
|
||||
onSurveySelect: (surveyId: string | null) => void;
|
||||
onQuestionToggle: (questionId: string) => void;
|
||||
onSelectAllQuestions: (surveyId: string) => void;
|
||||
onDeselectAllQuestions: () => void;
|
||||
}
|
||||
|
||||
function getQuestionIcon(type: TFormbricksSurveyQuestion["type"]) {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "rating":
|
||||
case "nps":
|
||||
case "csat":
|
||||
return <StarIcon className="h-4 w-4 text-amber-500" />;
|
||||
default:
|
||||
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: TFormbricksSurvey["status"]) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge text="Active" type="success" size="tiny" />;
|
||||
case "paused":
|
||||
return <Badge text="Paused" type="warning" size="tiny" />;
|
||||
case "draft":
|
||||
return <Badge text="Draft" type="gray" size="tiny" />;
|
||||
case "completed":
|
||||
return <Badge text="Completed" type="gray" size="tiny" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function FormbricksSurveySelector({
|
||||
selectedSurveyId,
|
||||
selectedQuestionIds,
|
||||
onSurveySelect,
|
||||
onQuestionToggle,
|
||||
onSelectAllQuestions,
|
||||
onDeselectAllQuestions,
|
||||
}: FormbricksSurveySelectorProps) {
|
||||
const [expandedSurveyId, setExpandedSurveyId] = useState<string | null>(null);
|
||||
|
||||
const selectedSurvey = MOCK_FORMBRICKS_SURVEYS.find((s) => s.id === selectedSurveyId);
|
||||
|
||||
const handleSurveyClick = (survey: TFormbricksSurvey) => {
|
||||
if (selectedSurveyId === survey.id) {
|
||||
// Toggle expand/collapse if already selected
|
||||
setExpandedSurveyId(expandedSurveyId === survey.id ? null : survey.id);
|
||||
} else {
|
||||
// Select the survey and expand it
|
||||
onSurveySelect(survey.id);
|
||||
onDeselectAllQuestions();
|
||||
setExpandedSurveyId(survey.id);
|
||||
}
|
||||
};
|
||||
|
||||
const allQuestionsSelected =
|
||||
selectedSurvey && selectedQuestionIds.length === selectedSurvey.questions.length;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left: Survey List */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Select Survey</h4>
|
||||
<div className="space-y-2">
|
||||
{MOCK_FORMBRICKS_SURVEYS.map((survey) => {
|
||||
const isSelected = selectedSurveyId === survey.id;
|
||||
const isExpanded = expandedSurveyId === survey.id;
|
||||
|
||||
return (
|
||||
<div key={survey.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSurveyClick(survey)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? "border-brand-dark bg-slate-50"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-100">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-600" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4 text-slate-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-900">{survey.name}</span>
|
||||
{getStatusBadge(survey.status)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{survey.questions.length} questions · {survey.responseCount.toLocaleString()} responses
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <CheckCircle2Icon className="text-brand-dark h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Question Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Select Questions</h4>
|
||||
{selectedSurvey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
allQuestionsSelected ? onDeselectAllQuestions() : onSelectAllQuestions(selectedSurvey.id)
|
||||
}
|
||||
className="text-xs text-slate-500 hover:text-slate-700">
|
||||
{allQuestionsSelected ? "Deselect all" : "Select all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedSurvey ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">Select a survey to see its questions</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedSurvey.questions.map((question) => {
|
||||
const isSelected = selectedQuestionIds.includes(question.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={question.id}
|
||||
type="button"
|
||||
onClick={() => onQuestionToggle(question.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
isSelected ? "bg-green-500 text-white" : "border border-slate-300 bg-white"
|
||||
}`}>
|
||||
{isSelected && <CheckIcon className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{getQuestionIcon(question.type)}</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-900">{question.headline}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{getQuestionTypeLabel(question.type)}</span>
|
||||
{question.required && (
|
||||
<span className="text-xs text-red-500">
|
||||
<CircleIcon className="inline h-1.5 w-1.5 fill-current" /> Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedQuestionIds.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<strong>{selectedQuestionIds.length}</strong> question
|
||||
{selectedQuestionIds.length !== 1 ? "s" : ""} selected. Each response to these questions
|
||||
will create a FeedbackRecord in the Hub.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
export { CreateSourceModal } from "./create-source-modal";
|
||||
export { CsvSourceUI } from "./csv-source-ui";
|
||||
export { EditSourceModal } from "./edit-source-modal";
|
||||
export { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
export { MappingUI } from "./mapping-ui";
|
||||
export { SourcesSection } from "./sources-page-client";
|
||||
export { SourcesTable } from "./sources-table";
|
||||
export { SourcesTableDataRow } from "./sources-table-data-row";
|
||||
export { SourceTypeSelector } from "./source-type-selector";
|
||||
export * from "./types";
|
||||
-305
@@ -1,305 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { TFieldMapping, TSourceField, TTargetField } from "./types";
|
||||
|
||||
interface DraggableSourceFieldProps {
|
||||
field: TSourceField;
|
||||
isMapped: boolean;
|
||||
}
|
||||
|
||||
export function DraggableSourceField({ field, isMapped }: DraggableSourceFieldProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isDragging
|
||||
? "border-brand-dark bg-slate-100 opacity-50"
|
||||
: isMapped
|
||||
? "border-green-300 bg-green-50 text-green-800"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
|
||||
</div>
|
||||
{field.sampleValue && (
|
||||
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DroppableTargetFieldProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
export function DroppableTargetField({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
isOver,
|
||||
}: DroppableTargetFieldProps) {
|
||||
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const [isEditingStatic, setIsEditingStatic] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
const isActive = isOver || isOverCurrent;
|
||||
const hasMapping = mappedSourceField || mapping?.staticValue;
|
||||
|
||||
// Handle enum field type - show dropdown
|
||||
if (field.type === "enum" && field.enumValues) {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
mapping?.staticValue ? "border-green-300 bg-green-50" : "border-dashed border-slate-300 bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">(enum)</span>
|
||||
</div>
|
||||
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||
<SelectTrigger className="h-8 w-full bg-white">
|
||||
<SelectValue placeholder="Select a value..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle string fields - allow drag & drop OR static value
|
||||
if (field.type === "string") {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? "border-brand-dark bg-slate-100"
|
||||
: hasMapping
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-dashed border-slate-300 bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
</div>
|
||||
|
||||
{/* Show mapped source field */}
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemoveMapping}
|
||||
className="ml-1 rounded p-0.5 hover:bg-green-100">
|
||||
<XIcon className="h-3 w-3 text-green-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show static value */}
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= "{mapping.staticValue}"
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemoveMapping}
|
||||
className="ml-1 rounded p-0.5 hover:bg-blue-100">
|
||||
<XIcon className="h-3 w-3 text-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show input for entering static value when editing */}
|
||||
{isEditingStatic && !hasMapping && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={
|
||||
field.exampleStaticValues ? `e.g., ${field.exampleStaticValues[0]}` : "Enter value..."
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
setIsEditingStatic(false);
|
||||
}}
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-200">
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show example values as quick select OR drop zone */}
|
||||
{!hasMapping && !isEditingStatic && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">Drop field or</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingStatic(true)}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
set value
|
||||
</button>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.slice(0, 3).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get display label for static values
|
||||
const getStaticValueLabel = (value: string) => {
|
||||
if (value === "$now") return "Feedback date";
|
||||
return value;
|
||||
};
|
||||
|
||||
// Default behavior for other field types (timestamp, float64, boolean, jsonb, etc.)
|
||||
const hasDefaultMapping = mappedSourceField || mapping?.staticValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? "border-brand-dark bg-slate-100"
|
||||
: hasDefaultMapping
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-dashed border-slate-300 bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">({field.type})</span>
|
||||
</div>
|
||||
|
||||
{/* Show mapped source field */}
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-green-100">
|
||||
<XIcon className="h-3 w-3 text-green-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show static value */}
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= {getStaticValueLabel(mapping.staticValue)}
|
||||
</span>
|
||||
<button type="button" onClick={onRemoveMapping} className="ml-1 rounded p-0.5 hover:bg-blue-100">
|
||||
<XIcon className="h-3 w-3 text-blue-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show drop zone with preset options */}
|
||||
{!hasDefaultMapping && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">Drop a field here</span>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{getStaticValueLabel(val)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-232
@@ -1,232 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
|
||||
import { CopyIcon, MailIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField, TSourceType } from "./types";
|
||||
|
||||
interface MappingUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
sourceType: TSourceType;
|
||||
deriveFromAttachments?: boolean;
|
||||
onDeriveFromAttachmentsChange?: (value: boolean) => void;
|
||||
emailInboxId?: string;
|
||||
}
|
||||
|
||||
export function MappingUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
sourceType,
|
||||
deriveFromAttachments = false,
|
||||
onDeriveFromAttachmentsChange,
|
||||
emailInboxId,
|
||||
}: MappingUIProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [emailCopied, setEmailCopied] = useState(false);
|
||||
|
||||
// Generate a stable random email ID if not provided
|
||||
const generatedEmailId = useMemo(() => {
|
||||
if (emailInboxId) return emailInboxId;
|
||||
return `fb-${Math.random().toString(36).substring(2, 8)}`;
|
||||
}, [emailInboxId]);
|
||||
|
||||
const inboxEmail = `${generatedEmailId}@inbox.formbricks.com`;
|
||||
|
||||
const handleCopyEmail = () => {
|
||||
navigator.clipboard.writeText(inboxEmail);
|
||||
setEmailCopied(true);
|
||||
setTimeout(() => setEmailCopied(false), 2000);
|
||||
};
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const sourceFieldId = active.id as string;
|
||||
const targetFieldId = over.id as string;
|
||||
|
||||
// Check if this target already has a mapping
|
||||
const existingMapping = mappings.find((m) => m.targetFieldId === targetFieldId);
|
||||
if (existingMapping) {
|
||||
// Remove the existing mapping first
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
} else {
|
||||
// Remove any existing mapping for this source field
|
||||
const newMappings = mappings.filter((m) => m.sourceFieldId !== sourceFieldId);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (targetFieldId: string) => {
|
||||
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
|
||||
};
|
||||
|
||||
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
|
||||
// Remove any existing mapping for this target field
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
// Add new static value mapping
|
||||
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
|
||||
};
|
||||
|
||||
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
|
||||
const getMappingForTarget = (targetFieldId: string) => {
|
||||
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
|
||||
};
|
||||
const getMappedSourceField = (targetFieldId: string) => {
|
||||
const mapping = getMappingForTarget(targetFieldId);
|
||||
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
|
||||
};
|
||||
const isSourceFieldMapped = (sourceFieldId: string) =>
|
||||
mappings.some((m) => m.sourceFieldId === sourceFieldId);
|
||||
|
||||
const activeField = activeId ? getSourceFieldById(activeId) : null;
|
||||
|
||||
const getSourceTypeLabel = () => {
|
||||
switch (sourceType) {
|
||||
case "webhook":
|
||||
return "Webhook Payload";
|
||||
case "email":
|
||||
return "Email Fields";
|
||||
case "csv":
|
||||
return "CSV Columns";
|
||||
default:
|
||||
return "Source Fields";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{/* Email inbox address display */}
|
||||
{sourceType === "email" && (
|
||||
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<MailIcon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-900">Your feedback inbox</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
Forward emails to this address to capture feedback automatically
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="rounded bg-white px-2 py-1 font-mono text-sm text-blue-700">
|
||||
{inboxEmail}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyEmail}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100">
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
{emailCopied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Source Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">{getSourceTypeLabel()}</h4>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">
|
||||
{sourceType === "webhook"
|
||||
? "Click 'Simulate webhook' to load sample fields"
|
||||
: sourceType === "email"
|
||||
? "Click 'Load email fields' to see available fields"
|
||||
: sourceType === "csv"
|
||||
? "Click 'Load sample CSV' to see columns"
|
||||
: "No source fields loaded yet"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sourceFields.map((field) => (
|
||||
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email-specific options */}
|
||||
{sourceType === "email" && onDeriveFromAttachmentsChange && (
|
||||
<div className="mt-4 flex items-center justify-between rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-900">Derive context from attachments</span>
|
||||
<Badge text="AI" type="gray" size="tiny" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
Extract additional context from email attachments using AI
|
||||
</span>
|
||||
</div>
|
||||
<Switch checked={deriveFromAttachments} onCheckedChange={onDeriveFromAttachmentsChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Hub Feedback Record Fields</h4>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Required</p>
|
||||
{requiredFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Optional Fields */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Optional</p>
|
||||
{optionalFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div className="border-brand-dark rounded-md border bg-white p-2 text-sm shadow-lg">
|
||||
<span className="font-medium">{activeField.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SOURCE_OPTIONS, TSourceType } from "./types";
|
||||
|
||||
interface SourceTypeSelectorProps {
|
||||
selectedType: TSourceType | null;
|
||||
onSelectType: (type: TSourceType) => void;
|
||||
}
|
||||
|
||||
export function SourceTypeSelector({ selectedType, onSelectType }: SourceTypeSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600">Select the type of feedback source you want to connect:</p>
|
||||
<div className="space-y-2">
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
|
||||
selectedType === option.id
|
||||
? "border-brand-dark bg-slate-50"
|
||||
: option.disabled
|
||||
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
|
||||
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-4 h-5 w-5 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { CreateSourceModal } from "./create-source-modal";
|
||||
import { EditSourceModal } from "./edit-source-modal";
|
||||
import { SourcesTable } from "./sources-table";
|
||||
import { TSourceConnection } from "./types";
|
||||
|
||||
interface SourcesSectionProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function SourcesSection({ environmentId }: SourcesSectionProps) {
|
||||
const [sources, setSources] = useState<TSourceConnection[]>([]);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingSource, setEditingSource] = useState<TSourceConnection | null>(null);
|
||||
|
||||
const handleCreateSource = (source: TSourceConnection) => {
|
||||
setSources((prev) => [...prev, source]);
|
||||
};
|
||||
|
||||
const handleUpdateSource = (updatedSource: TSourceConnection) => {
|
||||
setSources((prev) => prev.map((s) => (s.id === updatedSource.id ? updatedSource : s)));
|
||||
};
|
||||
|
||||
const handleDeleteSource = (sourceId: string) => {
|
||||
setSources((prev) => prev.filter((s) => s.id !== sourceId));
|
||||
};
|
||||
|
||||
const handleSourceClick = (source: TSourceConnection) => {
|
||||
setEditingSource(source);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle="Unify Feedback"
|
||||
cta={
|
||||
<CreateSourceModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateSource={handleCreateSource}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SourcesTable sources={sources} onSourceClick={handleSourceClick} />
|
||||
</div>
|
||||
|
||||
<EditSourceModal
|
||||
source={editingSource}
|
||||
open={editingSource !== null}
|
||||
onOpenChange={(open) => !open && setEditingSource(null)}
|
||||
onUpdateSource={handleUpdateSource}
|
||||
onDeleteSource={handleDeleteSource}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { FileSpreadsheetIcon, GlobeIcon, MailIcon, MessageSquareIcon, WebhookIcon } from "lucide-react";
|
||||
import { TSourceType } from "./types";
|
||||
|
||||
interface SourcesTableDataRowProps {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TSourceType;
|
||||
mappingsCount: number;
|
||||
createdAt: Date;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function getSourceIcon(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "webhook":
|
||||
return <WebhookIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "email":
|
||||
return <MailIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "slack":
|
||||
return <MessageSquareIcon className="h-4 w-4 text-slate-500" />;
|
||||
default:
|
||||
return <GlobeIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceTypeLabel(type: TSourceType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "Formbricks";
|
||||
case "webhook":
|
||||
return "Webhook";
|
||||
case "email":
|
||||
return "Email";
|
||||
case "csv":
|
||||
return "CSV";
|
||||
case "slack":
|
||||
return "Slack";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export function SourcesTableDataRow({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
mappingsCount,
|
||||
createdAt,
|
||||
onClick,
|
||||
}: SourcesTableDataRowProps) {
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onClick();
|
||||
}
|
||||
}}>
|
||||
<div className="col-span-1 flex items-center pl-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getSourceIcon(type)}
|
||||
<span className="hidden text-xs text-slate-500 sm:inline">{getSourceTypeLabel(type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-6 flex items-center">
|
||||
<span className="truncate font-medium text-slate-900">{name}</span>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-600 sm:flex">
|
||||
{mappingsCount} {mappingsCount === 1 ? "field" : "fields"}
|
||||
</div>
|
||||
<div className="col-span-3 hidden items-center justify-end pr-4 text-sm text-slate-500 sm:flex">
|
||||
{formatDistanceToNow(createdAt, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SourcesTableDataRow } from "./sources-table-data-row";
|
||||
import { TSourceConnection } from "./types";
|
||||
|
||||
interface SourcesTableProps {
|
||||
sources: TSourceConnection[];
|
||||
onSourceClick: (source: TSourceConnection) => void;
|
||||
}
|
||||
|
||||
export function SourcesTable({ sources, onSourceClick }: SourcesTableProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">Type</div>
|
||||
<div className="col-span-6">Name</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">Mappings</div>
|
||||
<div className="col-span-3 hidden pr-6 text-right sm:block">Created</div>
|
||||
</div>
|
||||
{sources.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">No sources connected yet. Add a source to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{sources.map((source) => (
|
||||
<SourcesTableDataRow
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
name={source.name}
|
||||
type={source.type}
|
||||
mappingsCount={source.mappings.length}
|
||||
createdAt={source.createdAt}
|
||||
onClick={() => onSourceClick(source)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-543
@@ -1,543 +0,0 @@
|
||||
// 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";
|
||||
@@ -1,10 +0,0 @@
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { SourcesSection } from "./components/sources-page-client";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
|
||||
const params = await props.params;
|
||||
|
||||
await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
return <SourcesSection environmentId={params.environmentId} />;
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
export type AddKeywordModalLevel = "L1" | "L2" | "L3";
|
||||
|
||||
interface AddKeywordModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
level: AddKeywordModalLevel;
|
||||
parentName?: string;
|
||||
onConfirm: (name: string) => void;
|
||||
}
|
||||
|
||||
export function AddKeywordModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
level,
|
||||
parentName,
|
||||
onConfirm,
|
||||
}: AddKeywordModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const handleClose = (nextOpen: boolean) => {
|
||||
if (!nextOpen) setName("");
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Please enter a keyword name.");
|
||||
return;
|
||||
}
|
||||
onConfirm(trimmed);
|
||||
setName("");
|
||||
onOpenChange(false);
|
||||
toast.success("Keyword added (demo).");
|
||||
};
|
||||
|
||||
const title =
|
||||
level === "L1" ? "Add L1 keyword" : level === "L2" ? "Add L2 keyword" : "Add L3 keyword";
|
||||
const description =
|
||||
level === "L1"
|
||||
? "Add a new top-level keyword."
|
||||
: parentName
|
||||
? `Add a new ${level} keyword under "${parentName}".`
|
||||
: `Add a new ${level} keyword.`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyword-name">Keyword name</Label>
|
||||
<Input
|
||||
id="keyword-name"
|
||||
placeholder="e.g. New category"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter className="m-2">
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add keyword</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LightbulbIcon,
|
||||
MessageCircleIcon,
|
||||
TriangleAlertIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { H4, Small } from "@/modules/ui/components/typography";
|
||||
import type { TaxonomyDetail, TaxonomyThemeItem } from "../types";
|
||||
|
||||
const THEME_COLORS: Record<string, string> = {
|
||||
red: "bg-red-400",
|
||||
orange: "bg-orange-400",
|
||||
yellow: "bg-amber-400",
|
||||
green: "bg-emerald-500",
|
||||
slate: "bg-slate-400",
|
||||
};;
|
||||
|
||||
function getThemeIcon(icon?: TaxonomyThemeItem["icon"]) {
|
||||
switch (icon) {
|
||||
case "warning":
|
||||
return <TriangleAlertIcon className="size-4 text-amber-500" />;
|
||||
case "wrench":
|
||||
return <WrenchIcon className="size-4 text-slate-500" />;
|
||||
case "message-circle":
|
||||
return <MessageCircleIcon className="size-4 text-slate-500" />;
|
||||
case "lightbulb":
|
||||
return <LightbulbIcon className="size-4 text-amber-500" />;
|
||||
default:
|
||||
return <MessageCircleIcon className="size-4 text-slate-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeItemRowProps {
|
||||
item: TaxonomyThemeItem;
|
||||
depth?: number;
|
||||
themeSearch: string;
|
||||
}
|
||||
|
||||
function ThemeItemRow({ item, depth = 0, themeSearch }: ThemeItemRowProps) {
|
||||
const [expanded, setExpanded] = useState(depth === 0 && (item.children?.length ?? 0) > 0);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const labelLower = item.label.toLowerCase();
|
||||
const matchesSearch =
|
||||
!themeSearch.trim() || labelLower.includes(themeSearch.trim().toLowerCase());
|
||||
const childMatches =
|
||||
hasChildren &&
|
||||
item.children!.some((c) =>
|
||||
c.label.toLowerCase().includes(themeSearch.trim().toLowerCase())
|
||||
);
|
||||
const show = matchesSearch || childMatches;
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 text-sm",
|
||||
depth === 0 ? "font-medium text-slate-800" : "text-slate-600"
|
||||
)}
|
||||
style={{ paddingLeft: depth * 16 + 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||
className="flex shrink-0 items-center justify-center text-slate-400 hover:text-slate-600">
|
||||
{hasChildren ? (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform", expanded && "rotate-90")}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
</button>
|
||||
{getThemeIcon(item.icon)}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<Small color="muted" className="shrink-0">
|
||||
{item.count}
|
||||
</Small>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div className="border-l border-slate-200 pl-2">
|
||||
{item.children!.map((child) => (
|
||||
<ThemeItemRow
|
||||
key={child.id}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
themeSearch={themeSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaxonomyDetailPanelProps {
|
||||
detail: TaxonomyDetail | null;
|
||||
}
|
||||
|
||||
export function TaxonomyDetailPanel({ detail }: TaxonomyDetailPanelProps) {
|
||||
const [themeSearch, setThemeSearch] = useState("");
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||
<Small color="muted">Select a Level 3 keyword to view details.</Small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalThemes = detail.themes.reduce((s, t) => s + t.count, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-col gap-5 overflow-y-auto p-4">
|
||||
<div className="border-b border-slate-200 pb-4">
|
||||
<H4 className="mb-1">{detail.keywordName}</H4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Small color="muted">{detail.count} responses</Small>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-slate-600 underline-offset-2 hover:underline">
|
||||
View all →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<H4 className="mb-1 text-sm">Description</H4>
|
||||
<Small color="muted" className="leading-relaxed">
|
||||
{detail.description}
|
||||
</Small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<H4 className="text-sm">{detail.themes.length} themes</H4>
|
||||
<div className="flex h-2 flex-1 max-w-[120px] overflow-hidden rounded-full bg-slate-100">
|
||||
{detail.themes.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(THEME_COLORS[t.color] ?? "bg-slate-400")}
|
||||
style={{
|
||||
width: totalThemes ? `${(t.count / totalThemes) * 100}%` : "0%",
|
||||
}}
|
||||
title={t.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search themes"
|
||||
value={themeSearch}
|
||||
onChange={(e) => setThemeSearch(e.target.value)}
|
||||
className="mb-3 h-9 text-sm"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
{detail.themeItems.map((item) => (
|
||||
<ThemeItemRow key={item.id} item={item} themeSearch={themeSearch} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatCount } from "../lib/mock-data";
|
||||
import type { TaxonomyKeyword } from "../types";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface TaxonomyKeywordColumnProps {
|
||||
title: string;
|
||||
keywords: TaxonomyKeyword[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
addButtonLabel?: string;
|
||||
onAdd?: () => void;
|
||||
}
|
||||
|
||||
export function TaxonomyKeywordColumn({
|
||||
title,
|
||||
keywords,
|
||||
selectedId,
|
||||
onSelect,
|
||||
addButtonLabel,
|
||||
onAdd,
|
||||
}: TaxonomyKeywordColumnProps) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
|
||||
</div>
|
||||
<div className="flex min-h-[320px] flex-1 flex-col overflow-y-auto">
|
||||
{keywords.map((kw) => (
|
||||
<button
|
||||
key={kw.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(kw.id)}
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr,auto] content-center gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors last:border-b-0",
|
||||
selectedId === kw.id ? "bg-slate-50" : "hover:bg-slate-50"
|
||||
)}>
|
||||
<span className="min-w-0 truncate text-sm font-medium text-slate-800">{kw.name}</span>
|
||||
<span className="text-sm text-slate-500">{formatCount(kw.count)}</span>
|
||||
</button>
|
||||
))}
|
||||
{addButtonLabel && (
|
||||
<div className="border-t border-slate-200 p-2">
|
||||
<Button type="button" variant="outline" size="sm" className="w-full" onClick={onAdd}>
|
||||
+ {addButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { getDetailForL3, getL2Keywords, getL3Keywords, MOCK_LEVEL1_KEYWORDS } from "../lib/mock-data";
|
||||
import type { TaxonomyKeyword } from "../types";
|
||||
import { AddKeywordModal } from "./AddKeywordModal";
|
||||
import { TaxonomyDetailPanel } from "./TaxonomyDetailPanel";
|
||||
import { TaxonomyKeywordColumn } from "./TaxonomyKeywordColumn";
|
||||
|
||||
interface TaxonomySectionProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function TaxonomySection({ environmentId }: TaxonomySectionProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedL1Id, setSelectedL1Id] = useState<string | null>("l1-1");
|
||||
const [selectedL2Id, setSelectedL2Id] = useState<string | null>("l2-1a");
|
||||
const [selectedL3Id, setSelectedL3Id] = useState<string | null>("l3-1a");
|
||||
const [addKeywordModalOpen, setAddKeywordModalOpen] = useState(false);
|
||||
const [addKeywordModalLevel, setAddKeywordModalLevel] = useState<"L1" | "L2" | "L3">("L1");
|
||||
const [customL1Keywords, setCustomL1Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||
const [customL2Keywords, setCustomL2Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||
const [customL3Keywords, setCustomL3Keywords] = useState<TaxonomyKeyword[]>([]);
|
||||
|
||||
const l2Keywords = useMemo(() => {
|
||||
const fromMock = selectedL1Id ? getL2Keywords(selectedL1Id) : [];
|
||||
const custom = customL2Keywords.filter((k) => k.parentId === selectedL1Id);
|
||||
return [...fromMock, ...custom];
|
||||
}, [selectedL1Id, customL2Keywords]);
|
||||
|
||||
const l3Keywords = useMemo(() => {
|
||||
const fromMock = selectedL2Id ? getL3Keywords(selectedL2Id) : [];
|
||||
const custom = customL3Keywords.filter((k) => k.parentId === selectedL2Id);
|
||||
return [...fromMock, ...custom];
|
||||
}, [selectedL2Id, customL3Keywords]);
|
||||
const detail = useMemo(
|
||||
() => (selectedL3Id ? getDetailForL3(selectedL3Id) : null),
|
||||
[selectedL3Id]
|
||||
);
|
||||
|
||||
const l1Keywords = useMemo(
|
||||
() => [...MOCK_LEVEL1_KEYWORDS, ...customL1Keywords],
|
||||
[customL1Keywords]
|
||||
);
|
||||
|
||||
const filterKeywords = (list: TaxonomyKeyword[], q: string) => {
|
||||
if (!q.trim()) return list;
|
||||
const lower = q.trim().toLowerCase();
|
||||
return list.filter((k) => k.name.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const filteredL1 = useMemo(
|
||||
() => filterKeywords(l1Keywords, searchQuery),
|
||||
[l1Keywords, searchQuery]
|
||||
);
|
||||
const filteredL2 = useMemo(() => filterKeywords(l2Keywords, searchQuery), [l2Keywords, searchQuery]);
|
||||
const filteredL3 = useMemo(() => filterKeywords(l3Keywords, searchQuery), [l3Keywords, searchQuery]);
|
||||
|
||||
const selectedL1Name = useMemo(
|
||||
() => l1Keywords.find((k) => k.id === selectedL1Id)?.name,
|
||||
[l1Keywords, selectedL1Id]
|
||||
);
|
||||
const selectedL2Name = useMemo(
|
||||
() => l2Keywords.find((k) => k.id === selectedL2Id)?.name,
|
||||
[l2Keywords, selectedL2Id]
|
||||
);
|
||||
|
||||
const addKeywordParentName =
|
||||
addKeywordModalLevel === "L2" ? selectedL1Name : addKeywordModalLevel === "L3" ? selectedL2Name : undefined;
|
||||
|
||||
const handleAddKeyword = (name: string) => {
|
||||
if (addKeywordModalLevel === "L1") {
|
||||
const id = `custom-l1-${crypto.randomUUID()}`;
|
||||
setCustomL1Keywords((prev) => [...prev, { id, name, count: 0 }]);
|
||||
setSelectedL1Id(id);
|
||||
setSelectedL2Id(null);
|
||||
setSelectedL3Id(null);
|
||||
} else if (addKeywordModalLevel === "L2" && selectedL1Id) {
|
||||
const id = `custom-l2-${crypto.randomUUID()}`;
|
||||
setCustomL2Keywords((prev) => [
|
||||
...prev,
|
||||
{ id, name, count: 0, parentId: selectedL1Id },
|
||||
]);
|
||||
setSelectedL2Id(id);
|
||||
setSelectedL3Id(null);
|
||||
} else if (addKeywordModalLevel === "L3" && selectedL2Id) {
|
||||
const id = `custom-l3-${crypto.randomUUID()}`;
|
||||
setCustomL3Keywords((prev) => [
|
||||
...prev,
|
||||
{ id, name, count: 0, parentId: selectedL2Id },
|
||||
]);
|
||||
setSelectedL3Id(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Unify Feedback">
|
||||
<UnifyConfigNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Find in taxonomy..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<div className="lg:col-span-3">
|
||||
<TaxonomyKeywordColumn
|
||||
title={`Level 1 Keywords (${filteredL1.length})`}
|
||||
keywords={filteredL1}
|
||||
selectedId={selectedL1Id}
|
||||
onSelect={(id) => {
|
||||
setSelectedL1Id(id);
|
||||
setSelectedL2Id(null);
|
||||
setSelectedL3Id(null);
|
||||
}}
|
||||
addButtonLabel="Add L1 Keyword"
|
||||
onAdd={() => {
|
||||
setAddKeywordModalLevel("L1");
|
||||
setAddKeywordModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-3">
|
||||
<TaxonomyKeywordColumn
|
||||
title={`Level 2 Keywords (${filteredL2.length})`}
|
||||
keywords={filteredL2}
|
||||
selectedId={selectedL2Id}
|
||||
onSelect={(id) => {
|
||||
setSelectedL2Id(id);
|
||||
setSelectedL3Id(null);
|
||||
}}
|
||||
addButtonLabel="Add L2 Keyword"
|
||||
onAdd={() => {
|
||||
setAddKeywordModalLevel("L2");
|
||||
setAddKeywordModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-3">
|
||||
<TaxonomyKeywordColumn
|
||||
title={`Level 3 Keywords (${filteredL3.length})`}
|
||||
keywords={filteredL3}
|
||||
selectedId={selectedL3Id}
|
||||
onSelect={setSelectedL3Id}
|
||||
addButtonLabel="Add L3 Keyword"
|
||||
onAdd={() => {
|
||||
setAddKeywordModalLevel("L3");
|
||||
setAddKeywordModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-[400px] lg:col-span-3">
|
||||
<TaxonomyDetailPanel detail={detail} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddKeywordModal
|
||||
open={addKeywordModalOpen}
|
||||
onOpenChange={setAddKeywordModalOpen}
|
||||
level={addKeywordModalLevel}
|
||||
parentName={addKeywordParentName}
|
||||
onConfirm={handleAddKeyword}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
import type { TaxonomyDetail, TaxonomyKeyword, TaxonomyThemeItem } from "../types";
|
||||
|
||||
export const MOCK_LEVEL1_KEYWORDS: TaxonomyKeyword[] = [
|
||||
{ id: "l1-1", name: "Dashboard", count: 12400 },
|
||||
{ id: "l1-2", name: "Usability", count: 8200 },
|
||||
{ id: "l1-3", name: "Performance", count: 5600 },
|
||||
{ id: "l1-4", name: "Miscellaneous", count: 3100 },
|
||||
];
|
||||
|
||||
export const MOCK_LEVEL2_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
|
||||
"l1-1": [
|
||||
{ id: "l2-1a", name: "Survey overview", count: 5200, parentId: "l1-1" },
|
||||
{ id: "l2-2a", name: "Response metrics", count: 3800, parentId: "l1-1" },
|
||||
{ id: "l2-3a", name: "Analytics & reports", count: 2400, parentId: "l1-1" },
|
||||
{ id: "l2-4a", name: "Widgets & embedding", count: 800, parentId: "l1-1" },
|
||||
{ id: "l2-5a", name: "Not specified", count: 200, parentId: "l1-1" },
|
||||
],
|
||||
"l1-2": [
|
||||
{ id: "l2-1b", name: "Survey builder", count: 3200, parentId: "l1-2" },
|
||||
{ id: "l2-2b", name: "Question types", count: 2100, parentId: "l1-2" },
|
||||
{ id: "l2-3b", name: "Logic & branching", count: 1400, parentId: "l1-2" },
|
||||
{ id: "l2-4b", name: "Styling & theming", count: 900, parentId: "l1-2" },
|
||||
{ id: "l2-5b", name: "Not specified", count: 600, parentId: "l1-2" },
|
||||
],
|
||||
"l1-3": [
|
||||
{ id: "l2-1c", name: "Load time & speed", count: 2200, parentId: "l1-3" },
|
||||
{ id: "l2-2c", name: "Survey rendering", count: 1600, parentId: "l1-3" },
|
||||
{ id: "l2-3c", name: "SDK & integration", count: 1100, parentId: "l1-3" },
|
||||
{ id: "l2-4c", name: "API & data sync", count: 500, parentId: "l1-3" },
|
||||
{ id: "l2-5c", name: "Not specified", count: 200, parentId: "l1-3" },
|
||||
],
|
||||
"l1-4": [
|
||||
{ id: "l2-1d", name: "Feature requests", count: 1500, parentId: "l1-4" },
|
||||
{ id: "l2-2d", name: "Bug reports", count: 900, parentId: "l1-4" },
|
||||
{ id: "l2-3d", name: "Documentation", count: 400, parentId: "l1-4" },
|
||||
{ id: "l2-4d", name: "Not specified", count: 300, parentId: "l1-4" },
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_LEVEL3_KEYWORDS: Record<string, TaxonomyKeyword[]> = {
|
||||
"l2-1a": [
|
||||
{ id: "l3-1a", name: "In-app surveys", count: 2800, parentId: "l2-1a" },
|
||||
{ id: "l3-2a", name: "Link surveys", count: 1600, parentId: "l2-1a" },
|
||||
{ id: "l3-3a", name: "Response summary", count: 600, parentId: "l2-1a" },
|
||||
{ id: "l3-4a", name: "Not specified", count: 200, parentId: "l2-1a" },
|
||||
],
|
||||
"l2-2a": [
|
||||
{ id: "l3-5a", name: "Completion rate", count: 1800, parentId: "l2-2a" },
|
||||
{ id: "l3-6a", name: "Drop-off points", count: 1200, parentId: "l2-2a" },
|
||||
{ id: "l3-7a", name: "Response distribution", count: 800, parentId: "l2-2a" },
|
||||
],
|
||||
"l2-1b": [
|
||||
{ id: "l3-1b", name: "Drag & drop editor", count: 1400, parentId: "l2-1b" },
|
||||
{ id: "l3-2b", name: "Question configuration", count: 900, parentId: "l2-1b" },
|
||||
{ id: "l3-3b", name: "Multi-language surveys", count: 500, parentId: "l2-1b" },
|
||||
{ id: "l3-4b", name: "Not specified", count: 400, parentId: "l2-1b" },
|
||||
],
|
||||
"l2-2b": [
|
||||
{ id: "l3-5b", name: "Open text & NPS", count: 1100, parentId: "l2-2b" },
|
||||
{ id: "l3-6b", name: "Multiple choice & rating", count: 600, parentId: "l2-2b" },
|
||||
{ id: "l3-7b", name: "File upload & date picker", count: 400, parentId: "l2-2b" },
|
||||
],
|
||||
"l2-1c": [
|
||||
{ id: "l3-1c", name: "Widget initialization", count: 900, parentId: "l2-1c" },
|
||||
{ id: "l3-2c", name: "Survey load delay", count: 700, parentId: "l2-1c" },
|
||||
{ id: "l3-3c", name: "Bundle size impact", count: 600, parentId: "l2-1c" },
|
||||
],
|
||||
"l2-1d": [
|
||||
{ id: "l3-1d", name: "New question types", count: 600, parentId: "l2-1d" },
|
||||
{ id: "l3-2d", name: "Integrations & webhooks", count: 500, parentId: "l2-1d" },
|
||||
{ id: "l3-3d", name: "Export & reporting", count: 400, parentId: "l2-1d" },
|
||||
],
|
||||
};
|
||||
|
||||
export function getL2Keywords(parentL1Id: string): TaxonomyKeyword[] {
|
||||
return MOCK_LEVEL2_KEYWORDS[parentL1Id] ?? [];
|
||||
}
|
||||
|
||||
export function getL3Keywords(parentL2Id: string): TaxonomyKeyword[] {
|
||||
return MOCK_LEVEL3_KEYWORDS[parentL2Id] ?? [];
|
||||
}
|
||||
|
||||
export const MOCK_DETAIL_L3: Record<string, TaxonomyDetail> = {
|
||||
"l3-1a": {
|
||||
keywordId: "l3-1a",
|
||||
keywordName: "In-app surveys",
|
||||
count: 2800,
|
||||
description:
|
||||
"Feedback collected directly inside your product. Formbricks in-app surveys are triggered by actions (e.g. page view, click) and can be shown as modal, full-width, or inline widgets.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Issues", count: 1200, color: "red" },
|
||||
{ id: "t2", label: "Ideas", count: 900, color: "orange" },
|
||||
{ id: "t3", label: "Questions", count: 500, color: "yellow" },
|
||||
{ id: "t4", label: "Other", count: 200, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{
|
||||
id: "ti-1",
|
||||
label: "Survey not showing on trigger",
|
||||
count: 420,
|
||||
icon: "warning",
|
||||
children: [
|
||||
{ id: "ti-1-1", label: "Wrong environment or survey ID", count: 200 },
|
||||
{ id: "ti-1-2", label: "Trigger conditions not met", count: 150 },
|
||||
{ id: "ti-1-3", label: "SDK not loaded in time", count: 70 },
|
||||
],
|
||||
},
|
||||
{ id: "ti-2", label: "Positioning and placement", count: 310, icon: "wrench" },
|
||||
{ id: "ti-3", label: "Request for more trigger types", count: 280, icon: "lightbulb" },
|
||||
{ id: "ti-4", label: "Miscellaneous in-app feedback", count: 190, icon: "message-circle" },
|
||||
],
|
||||
},
|
||||
"l3-1b": {
|
||||
keywordId: "l3-1b",
|
||||
keywordName: "Drag & drop editor",
|
||||
count: 1400,
|
||||
description:
|
||||
"The Formbricks survey builder lets you add and reorder questions with drag and drop, configure question settings, and preview surveys before publishing.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Issues", count: 600, color: "red" },
|
||||
{ id: "t2", label: "Ideas", count: 500, color: "orange" },
|
||||
{ id: "t3", label: "Questions", count: 250, color: "yellow" },
|
||||
{ id: "t4", label: "Other", count: 50, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{ id: "ti-1", label: "Reordering fails with many questions", count: 220, icon: "warning" },
|
||||
{ id: "ti-2", label: "Request for keyboard shortcuts", count: 180, icon: "lightbulb" },
|
||||
{ id: "ti-3", label: "Undo / redo in editor", count: 150, icon: "lightbulb" },
|
||||
{ id: "ti-4", label: "Miscellaneous builder feedback", count: 100, icon: "message-circle" },
|
||||
],
|
||||
},
|
||||
"l3-1c": {
|
||||
keywordId: "l3-1c",
|
||||
keywordName: "Widget initialization",
|
||||
count: 900,
|
||||
description:
|
||||
"How quickly the Formbricks widget loads and becomes ready to display surveys. Includes script load time, SDK init, and first-paint for survey UI.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Issues", count: 550, color: "red" },
|
||||
{ id: "t2", label: "Ideas", count: 250, color: "orange" },
|
||||
{ id: "t3", label: "Questions", count: 100, color: "yellow" },
|
||||
{ id: "t4", label: "Other", count: 0, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{ id: "ti-1", label: "Slow init on mobile networks", count: 280, icon: "warning" },
|
||||
{ id: "ti-2", label: "Blocking main thread", count: 180, icon: "warning" },
|
||||
{ id: "ti-3", label: "Lazy-load SDK suggestion", count: 120, icon: "lightbulb" },
|
||||
],
|
||||
},
|
||||
"l3-1d": {
|
||||
keywordId: "l3-1d",
|
||||
keywordName: "New question types",
|
||||
count: 600,
|
||||
description:
|
||||
"Requests for additional question types in Formbricks surveys (e.g. matrix, ranking, sliders, image choice) to capture different kinds of feedback.",
|
||||
themes: [
|
||||
{ id: "t1", label: "Ideas", count: 450, color: "orange" },
|
||||
{ id: "t2", label: "Questions", count: 100, color: "yellow" },
|
||||
{ id: "t3", label: "Other", count: 50, color: "green" },
|
||||
],
|
||||
themeItems: [
|
||||
{ id: "ti-1", label: "Matrix / grid question", count: 180, icon: "lightbulb" },
|
||||
{ id: "ti-2", label: "Ranking question type", count: 120, icon: "lightbulb" },
|
||||
{ id: "ti-3", label: "Slider and scale variants", count: 90, icon: "lightbulb" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getDetailForL3(keywordId: string): TaxonomyDetail | null {
|
||||
return MOCK_DETAIL_L3[keywordId] ?? null;
|
||||
}
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
+6
-5
@@ -9,17 +9,18 @@
|
||||
"source": "en-US",
|
||||
"targets": [
|
||||
"de-DE",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"ru-RU"
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
+223
-217
File diff suppressed because it is too large
Load Diff
@@ -165,19 +165,20 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"zh-Hant-TW",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
+38
-32
@@ -126,12 +126,6 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
|
||||
};
|
||||
|
||||
export const appLanguages = [
|
||||
{
|
||||
code: "en-US",
|
||||
label: {
|
||||
"en-US": "English (US)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "de-DE",
|
||||
label: {
|
||||
@@ -139,9 +133,15 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
code: "en-US",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
"en-US": "English (US)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -151,9 +151,27 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
code: "hu-HU",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
"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)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -169,27 +187,9 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hans-CN",
|
||||
label: {
|
||||
"en-US": "Chinese (Simplified)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -199,9 +199,15 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
code: "zh-Hans-CN",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
"en-US": "Chinese (Simplified)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+15
-13
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, hu, 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,28 +87,30 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return de;
|
||||
case "en-US":
|
||||
return enUS;
|
||||
case "pt-BR":
|
||||
return ptBR;
|
||||
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 "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "pt-BR":
|
||||
return ptBR;
|
||||
case "pt-PT":
|
||||
return pt;
|
||||
case "ro-RO":
|
||||
return ro;
|
||||
case "ja-JP":
|
||||
return ja;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "ru-RU":
|
||||
return ru;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Helle Überlagerung",
|
||||
"limits_reached": "Limits erreicht",
|
||||
"link": "Link",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Testlizenz anfordern",
|
||||
"reset_to_default": "Auf Standard zurücksetzen",
|
||||
"response": "Antwort",
|
||||
"response_id": "Antwort-ID",
|
||||
"responses": "Antworten",
|
||||
"restart": "Neustart",
|
||||
"role": "Rolle",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Annehmen",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "von deiner Organisation",
|
||||
"invitation_sent_once_more": "Einladung nochmal gesendet.",
|
||||
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
|
||||
"invited_on": "Eingeladen am {date}",
|
||||
"invite_expires_on": "Einladung läuft ab am {date}",
|
||||
"invites_failed": "Einladungen fehlgeschlagen",
|
||||
"leave_organization": "Organisation verlassen",
|
||||
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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.",
|
||||
|
||||
+224
-218
File diff suppressed because it is too large
Load Diff
@@ -254,6 +254,7 @@
|
||||
"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",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Solicitar licencia de prueba",
|
||||
"reset_to_default": "Restablecer a valores predeterminados",
|
||||
"response": "Respuesta",
|
||||
"response_id": "ID de respuesta",
|
||||
"responses": "Respuestas",
|
||||
"restart": "Reiniciar",
|
||||
"role": "Rol",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceptar",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "de tu organización",
|
||||
"invitation_sent_once_more": "Invitación enviada una vez más.",
|
||||
"invite_deleted_successfully": "Invitación eliminada correctamente",
|
||||
"invited_on": "Invitado el {date}",
|
||||
"invite_expires_on": "La invitación expira el {date}",
|
||||
"invites_failed": "Las invitaciones fallaron",
|
||||
"leave_organization": "Abandonar organización",
|
||||
"leave_organization_description": "Abandonarás esta organización y perderás acceso a todas las encuestas y respuestas. Solo podrás volver a unirte si te invitan de nuevo.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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,6 +254,7 @@
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"learn_more": "En savoir plus",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Claire",
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Demander une licence d'essai",
|
||||
"reset_to_default": "Réinitialiser par défaut",
|
||||
"response": "Réponse",
|
||||
"response_id": "ID de réponse",
|
||||
"responses": "Réponses",
|
||||
"restart": "Recommencer",
|
||||
"role": "Rôle",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Accepter",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "de votre organisation",
|
||||
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
|
||||
"invite_deleted_successfully": "Invitation supprimée avec succès",
|
||||
"invited_on": "Invité le {date}",
|
||||
"invite_expires_on": "L'invitation expire le {date}",
|
||||
"invites_failed": "Invitations échouées",
|
||||
"leave_organization": "Quitter l'organisation",
|
||||
"leave_organization_description": "Vous quitterez cette organisation et perdrez l'accès à toutes les enquêtes et réponses. Vous ne pourrez revenir que si vous êtes de nouveau invité.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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.",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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,6 +254,7 @@
|
||||
"label": "ラベル",
|
||||
"language": "言語",
|
||||
"learn_more": "詳細を見る",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "明るいオーバーレイ",
|
||||
"limits_reached": "上限に達しました",
|
||||
"link": "リンク",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "トライアルライセンスをリクエスト",
|
||||
"reset_to_default": "デフォルトにリセット",
|
||||
"response": "回答",
|
||||
"response_id": "回答ID",
|
||||
"responses": "回答",
|
||||
"restart": "再開",
|
||||
"role": "役割",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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} に行われます。"
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "承認",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "あなたの組織から",
|
||||
"invitation_sent_once_more": "招待状を再度送信しました。",
|
||||
"invite_deleted_successfully": "招待を正常に削除しました",
|
||||
"invited_on": "{date}に招待",
|
||||
"invite_expires_on": "招待は{date}に期限切れ",
|
||||
"invites_failed": "招待に失敗しました",
|
||||
"leave_organization": "組織を離れる",
|
||||
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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": "で終わらない",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"does_not_include_all_of": "のすべてを含まない",
|
||||
"does_not_include_one_of": "のいずれも含まない",
|
||||
"does_not_start_with": "で始まらない",
|
||||
"dropdown": "ドロップダウン",
|
||||
"duplicate_block": "ブロックを複製",
|
||||
"duplicate_question": "質問を複製",
|
||||
"edit_link": "編集 リンク",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
|
||||
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
|
||||
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
|
||||
"list": "リスト",
|
||||
"load_segment": "セグメントを読み込み",
|
||||
"logic_error_warning": "変更するとロジックエラーが発生します",
|
||||
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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,6 +254,7 @@
|
||||
"label": "Label",
|
||||
"language": "Taal",
|
||||
"learn_more": "Meer informatie",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Lichte overlay",
|
||||
"limits_reached": "Grenzen bereikt",
|
||||
"link": "Link",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Proeflicentie aanvragen",
|
||||
"reset_to_default": "Resetten naar standaard",
|
||||
"response": "Antwoord",
|
||||
"response_id": "Antwoord-ID",
|
||||
"responses": "Reacties",
|
||||
"restart": "Opnieuw opstarten",
|
||||
"role": "Rol",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Accepteren",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "vanuit uw organisatie",
|
||||
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
|
||||
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
|
||||
"invited_on": "Uitgenodigd op {date}",
|
||||
"invite_expires_on": "Uitnodiging verloopt op {date}",
|
||||
"invites_failed": "Uitnodigingen zijn mislukt",
|
||||
"leave_organization": "Verlaat de organisatie",
|
||||
"leave_organization_description": "U verlaat deze organisatie en verliest de toegang tot alle enquêtes en reacties. Je kunt alleen weer meedoen als je opnieuw wordt uitgenodigd.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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 \"{quotaName}\" quotum",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele “{variableName}” wordt gebruikt in het quotum “{quotaName}”",
|
||||
"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,6 +254,7 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Língua",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "link",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Pedir licença de teste",
|
||||
"reset_to_default": "Restaurar para o padrão",
|
||||
"response": "Resposta",
|
||||
"response_id": "ID da resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"role": "Rolê",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceitar",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "da sua organização",
|
||||
"invitation_sent_once_more": "Convite enviado de novo.",
|
||||
"invite_deleted_successfully": "Convite deletado com sucesso",
|
||||
"invited_on": "Convidado em {date}",
|
||||
"invite_expires_on": "O convite expira em {date}",
|
||||
"invites_failed": "Convites falharam",
|
||||
"leave_organization": "Sair da organização",
|
||||
"leave_organization_description": "Você vai sair dessa organização e perder acesso a todas as pesquisas e respostas. Você só pode voltar se for convidado de novo.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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",
|
||||
@@ -1469,7 +1475,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 questão está sendo usada na cota \"{quotaName}\"",
|
||||
"question_used_in_quota": "Esta pergunta 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": {
|
||||
@@ -1643,7 +1649,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": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A 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,6 +254,7 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Solicitar licença de teste",
|
||||
"reset_to_default": "Repor para o padrão",
|
||||
"response": "Resposta",
|
||||
"response_id": "ID de resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"role": "Função",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceitar",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "da sua organização",
|
||||
"invitation_sent_once_more": "Convite enviado mais uma vez.",
|
||||
"invite_deleted_successfully": "Convite eliminado com sucesso",
|
||||
"invited_on": "Convidado em {date}",
|
||||
"invite_expires_on": "O convite expira em {date}",
|
||||
"invites_failed": "Convites falharam",
|
||||
"leave_organization": "Sair da organização",
|
||||
"leave_organization_description": "Vai sair desta organização e perder o acesso a todos os inquéritos e respostas. Só pode voltar a juntar-se se for convidado novamente.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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",
|
||||
@@ -1643,7 +1649,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": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A variável \"{variableName}\" está a ser usada 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,6 +254,7 @@
|
||||
"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",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Solicitați o licență de încercare",
|
||||
"reset_to_default": "Revino la implicit",
|
||||
"response": "Răspuns",
|
||||
"response_id": "ID răspuns",
|
||||
"responses": "Răspunsuri",
|
||||
"restart": "Repornește",
|
||||
"role": "Rolul",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Acceptă",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "din organizația ta",
|
||||
"invitation_sent_once_more": "Invitație trimisă din nou.",
|
||||
"invite_deleted_successfully": "Invitație ștearsă cu succes",
|
||||
"invited_on": "Invitat pe {date}",
|
||||
"invite_expires_on": "Invitația expiră pe {date}",
|
||||
"invites_failed": "Invitații eșuate",
|
||||
"leave_organization": "Părăsește organizația",
|
||||
"leave_organization_description": "Vei părăsi această organizație și vei pierde accesul la toate sondajele și răspunsurile. Poți să te alături din nou doar dacă ești invitat.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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ă",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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}\"",
|
||||
"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_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,6 +254,7 @@
|
||||
"label": "Метка",
|
||||
"language": "Язык",
|
||||
"learn_more": "Подробнее",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Светлый оверлей",
|
||||
"limits_reached": "Достигнуты лимиты",
|
||||
"link": "Ссылка",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Запросить пробную лицензию",
|
||||
"reset_to_default": "Сбросить по умолчанию",
|
||||
"response": "Ответ",
|
||||
"response_id": "ID ответа",
|
||||
"responses": "Ответы",
|
||||
"restart": "Перезапустить",
|
||||
"role": "Роль",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Принять",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "из вашей организации",
|
||||
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
|
||||
"invite_deleted_successfully": "Приглашение успешно удалено",
|
||||
"invited_on": "Приглашён {date}",
|
||||
"invite_expires_on": "Приглашение истекает {date}",
|
||||
"invites_failed": "Не удалось отправить приглашения",
|
||||
"leave_organization": "Покинуть организацию",
|
||||
"leave_organization_description": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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": "Не заканчивается на",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"does_not_include_all_of": "Не включает все из",
|
||||
"does_not_include_one_of": "Не включает ни одного из",
|
||||
"does_not_start_with": "Не начинается с",
|
||||
"dropdown": "Выпадающий список",
|
||||
"duplicate_block": "Дублировать блок",
|
||||
"duplicate_question": "Дублировать вопрос",
|
||||
"edit_link": "Редактировать ссылку",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"limit_the_maximum_file_size": "Ограничьте максимальный размер загружаемых файлов.",
|
||||
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
|
||||
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
|
||||
"list": "Список",
|
||||
"load_segment": "Загрузить сегмент",
|
||||
"logic_error_warning": "Изменение приведёт к логическим ошибкам",
|
||||
"logic_error_warning_text": "Изменение типа вопроса удалит логические условия из этого вопроса",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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,6 +254,7 @@
|
||||
"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",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "Begär provlicens",
|
||||
"reset_to_default": "Återställ till standard",
|
||||
"response": "Svar",
|
||||
"response_id": "Svar-ID",
|
||||
"responses": "Svar",
|
||||
"restart": "Starta om",
|
||||
"role": "Roll",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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}."
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Acceptera",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "från din organisation",
|
||||
"invitation_sent_once_more": "Inbjudan skickad igen.",
|
||||
"invite_deleted_successfully": "Inbjudan borttagen",
|
||||
"invited_on": "Inbjuden den {date}",
|
||||
"invite_expires_on": "Inbjudan går ut den {date}",
|
||||
"invites_failed": "Inbjudningar misslyckades",
|
||||
"leave_organization": "Lämna organisation",
|
||||
"leave_organization_description": "Du kommer att lämna denna organisation och förlora åtkomst till alla enkäter och svar. Du kan endast återansluta om du blir inbjuden igen.",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"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",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"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",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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": "Variabel \"{variableName}\" används i kvoten \"{quotaName}\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabeln “{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,6 +254,7 @@
|
||||
"label": "标签",
|
||||
"language": "语言",
|
||||
"learn_more": "了解 更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "浅色遮罩层",
|
||||
"limits_reached": "限制 达到",
|
||||
"link": "链接",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "申请试用许可证",
|
||||
"reset_to_default": "重置为 默认",
|
||||
"response": "响应",
|
||||
"response_id": "响应 ID",
|
||||
"responses": "反馈",
|
||||
"restart": "重新启动",
|
||||
"role": "角色",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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} 降级到社区版。"
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "接受",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "来自你的组织",
|
||||
"invitation_sent_once_more": "再次发送邀请。",
|
||||
"invite_deleted_successfully": "邀请 删除 成功",
|
||||
"invited_on": "受邀于 {date}",
|
||||
"invite_expires_on": "邀请将于 {date} 过期",
|
||||
"invites_failed": "邀请失败",
|
||||
"leave_organization": "离开 组织",
|
||||
"leave_organization_description": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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": "不 以 结尾",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"does_not_include_all_of": "不包括所有 ",
|
||||
"does_not_include_one_of": "不包括一 个",
|
||||
"does_not_start_with": "不 以 开头",
|
||||
"dropdown": "下拉菜单",
|
||||
"duplicate_block": "复制区块",
|
||||
"duplicate_question": "复制问题",
|
||||
"edit_link": "编辑 链接",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"limit_the_maximum_file_size": "限制上传文件的最大大小。",
|
||||
"limit_upload_file_size_to": "将上传文件大小限制为",
|
||||
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
|
||||
"list": "列表",
|
||||
"load_segment": "载入 段落",
|
||||
"logic_error_warning": "更改 将 导致 逻辑 错误",
|
||||
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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,6 +254,7 @@
|
||||
"label": "標籤",
|
||||
"language": "語言",
|
||||
"learn_more": "瞭解更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "淺色覆蓋",
|
||||
"limits_reached": "已達上限",
|
||||
"link": "連結",
|
||||
@@ -349,6 +350,7 @@
|
||||
"request_trial_license": "請求試用授權",
|
||||
"reset_to_default": "重設為預設值",
|
||||
"response": "回應",
|
||||
"response_id": "回應 ID",
|
||||
"responses": "回應",
|
||||
"restart": "重新開始",
|
||||
"role": "角色",
|
||||
@@ -460,7 +462,8 @@
|
||||
"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'}' 降級至社群版。"
|
||||
"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."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "接受",
|
||||
@@ -987,7 +990,7 @@
|
||||
"from_your_organization": "來自您的組織",
|
||||
"invitation_sent_once_more": "已再次發送邀請。",
|
||||
"invite_deleted_successfully": "邀請已成功刪除",
|
||||
"invited_on": "邀請於 '{'date'}'",
|
||||
"invite_expires_on": "邀請將於 '{'date'}' 過期",
|
||||
"invites_failed": "邀請失敗",
|
||||
"leave_organization": "離開組織",
|
||||
"leave_organization_description": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"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": "不以...結尾",
|
||||
@@ -1278,6 +1282,7 @@
|
||||
"does_not_include_all_of": "不包含全部",
|
||||
"does_not_include_one_of": "不包含其中之一",
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"dropdown": "下拉選單",
|
||||
"duplicate_block": "複製區塊",
|
||||
"duplicate_question": "複製問題",
|
||||
"edit_link": "編輯 連結",
|
||||
@@ -1410,6 +1415,7 @@
|
||||
"limit_the_maximum_file_size": "限制上傳檔案的最大大小。",
|
||||
"limit_upload_file_size_to": "將上傳檔案大小限制為",
|
||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||
"list": "清單",
|
||||
"load_segment": "載入區隔",
|
||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
@@ -1469,7 +1475,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": {
|
||||
@@ -1643,7 +1649,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,6 +157,7 @@ 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 () => {
|
||||
@@ -233,6 +234,7 @@ describe("License Core Logic", () => {
|
||||
lastChecked: previousTime,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,6 +311,7 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,6 +359,7 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -389,6 +393,7 @@ 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();
|
||||
@@ -414,6 +419,7 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,17 @@ 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;
|
||||
@@ -90,7 +101,7 @@ class LicenseApiError extends LicenseError {
|
||||
|
||||
// Cache keys using enterprise-grade hierarchical patterns
|
||||
const getCacheIdentifier = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (globalThis.window !== undefined) {
|
||||
return "browser"; // Browser environment
|
||||
}
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) {
|
||||
@@ -142,36 +153,50 @@ const validateConfig = () => {
|
||||
};
|
||||
|
||||
// Cache functions with async pattern
|
||||
let getPreviousResultPromise: Promise<TPreviousResult> | null = null;
|
||||
|
||||
const getPreviousResult = async (): Promise<TPreviousResult> => {
|
||||
if (typeof window !== "undefined") {
|
||||
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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
getPreviousResultPromise
|
||||
.finally(() => {
|
||||
getPreviousResultPromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
};
|
||||
return getPreviousResultPromise;
|
||||
};
|
||||
|
||||
const setPreviousResult = async (previousResult: TPreviousResult) => {
|
||||
if (typeof window !== "undefined") return;
|
||||
if (globalThis.window !== undefined) return;
|
||||
|
||||
try {
|
||||
const result = await cache.set(
|
||||
@@ -221,12 +246,21 @@ 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) return "live";
|
||||
if (liveLicense?.status === "active") return "live";
|
||||
if (previousResult.active) {
|
||||
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
||||
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
|
||||
@@ -234,7 +268,7 @@ const getFallbackLevel = (
|
||||
return "default";
|
||||
};
|
||||
|
||||
const handleInitialFailure = async (currentTime: Date) => {
|
||||
const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicenseResult> => {
|
||||
const initialFailResult: TPreviousResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
@@ -247,10 +281,13 @@ const handleInitialFailure = async (currentTime: Date) => {
|
||||
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;
|
||||
|
||||
@@ -266,6 +303,7 @@ 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
|
||||
@@ -279,6 +317,11 @@ 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)
|
||||
@@ -311,7 +354,19 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
|
||||
if (res.ok) {
|
||||
const responseJson = (await res.json()) as { data: unknown };
|
||||
return validateLicenseDetails(responseJson.data);
|
||||
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;
|
||||
}
|
||||
|
||||
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
||||
@@ -342,23 +397,41 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
|
||||
return null;
|
||||
}
|
||||
|
||||
return await cache.withCache(
|
||||
async () => {
|
||||
return await fetchLicenseFromServerInternal();
|
||||
},
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
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;
|
||||
};
|
||||
|
||||
export const getEnterpriseLicense = reactCache(
|
||||
async (): Promise<{
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
}> => {
|
||||
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 () => {
|
||||
validateConfig();
|
||||
|
||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
@@ -368,12 +441,11 @@ export const getEnterpriseLicense = reactCache(
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = new Date();
|
||||
const liveLicenseDetails = await fetchLicense();
|
||||
const previousResult = await getPreviousResult();
|
||||
const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]);
|
||||
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
|
||||
|
||||
trackFallbackUsage(fallbackLevel);
|
||||
@@ -381,41 +453,84 @@ export const getEnterpriseLicense = reactCache(
|
||||
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,
|
||||
};
|
||||
await setPreviousResult(currentLicenseState);
|
||||
return {
|
||||
|
||||
// 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 = {
|
||||
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 handleInitialFailure(currentTime);
|
||||
return await handleInitialFailure(currentTime);
|
||||
}
|
||||
return {
|
||||
const graceResult: TEnterpriseLicenseResult = {
|
||||
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":
|
||||
return handleInitialFailure(currentTime);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
);
|
||||
const finalFailResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: finalFailResult, timestamp: Date.now() };
|
||||
return finalFailResult;
|
||||
})();
|
||||
|
||||
getEnterpriseLicensePromise
|
||||
.finally(() => {
|
||||
getEnterpriseLicensePromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return getEnterpriseLicensePromise;
|
||||
});
|
||||
|
||||
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useTransition } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TI18nString } from "@formbricks/types/i18n";
|
||||
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
@@ -74,6 +74,8 @@ export function LocalizedEditor({
|
||||
[id, isInvalid, localSurvey.languages, value]
|
||||
);
|
||||
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Editor
|
||||
@@ -109,44 +111,45 @@ export function LocalizedEditor({
|
||||
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
|
||||
}
|
||||
|
||||
// Check if the elements still exists before updating
|
||||
const currentElement = elements[elementIdx];
|
||||
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = elementIdx === -1;
|
||||
const isEndingCard = elementIdx >= elements.length;
|
||||
startTransition(() => {
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = elementIdx === -1;
|
||||
const isEndingCard = elementIdx >= elements.length;
|
||||
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
|
||||
// If the field doesn't exist on the ending card, don't create it
|
||||
if (!ending || ending[id] === undefined) {
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
|
||||
// If the field doesn't exist on the ending card, don't create it
|
||||
if (!ending || ending[id] === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For welcome cards, check if it exists
|
||||
if (isWelcomeCard && !localSurvey.welcomeCard) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For welcome cards, check if it exists
|
||||
if (isWelcomeCard && !localSurvey.welcomeCard) {
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement({ [id]: translatedContent });
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement({ [id]: translatedContent });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the field exists on the element (not just if it's not undefined)
|
||||
if (currentElement && id in currentElement && currentElement[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement(elementIdx, { [id]: translatedContent });
|
||||
}
|
||||
// Check if the field exists on the element (not just if it's not undefined)
|
||||
if (currentElement && id in currentElement && currentElement[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement(elementIdx, { [id]: translatedContent });
|
||||
}
|
||||
});
|
||||
}}
|
||||
localSurvey={localSurvey}
|
||||
elementId={elementId}
|
||||
|
||||
@@ -15,6 +15,7 @@ type TEnterpriseLicense = {
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
status: "active" | "expired" | "unreachable" | "no-license";
|
||||
};
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId, ZUuid } from "@formbricks/types/common";
|
||||
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
getMembershipsByUserId,
|
||||
getOrganizationOwnerCount,
|
||||
} from "@/modules/organization/settings/teams/lib/membership";
|
||||
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
|
||||
import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInvite } from "./lib/invite";
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
@@ -57,30 +58,57 @@ const ZCreateInviteTokenAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
});
|
||||
|
||||
export const createInviteTokenAction = authenticatedActionClient
|
||||
.schema(ZCreateInviteTokenAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
export const createInviteTokenAction = authenticatedActionClient.schema(ZCreateInviteTokenAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"invite",
|
||||
async ({
|
||||
parsedInput,
|
||||
ctx,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateInviteTokenAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
const invite = await getInvite(parsedInput.inviteId);
|
||||
if (!invite) {
|
||||
throw new ValidationError("Invite not found");
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get old expiresAt for audit logging before update
|
||||
const oldInvite = await prisma.invite.findUnique({
|
||||
where: { id: parsedInput.inviteId },
|
||||
select: { email: true, expiresAt: true },
|
||||
});
|
||||
|
||||
if (!oldInvite) {
|
||||
throw new ValidationError("Invite not found");
|
||||
}
|
||||
|
||||
// Refresh the invitation expiration
|
||||
const updatedInvite = await refreshInviteExpiration(parsedInput.inviteId);
|
||||
|
||||
// Set audit context
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { expiresAt: oldInvite.expiresAt };
|
||||
ctx.auditLoggingCtx.newObject = { expiresAt: updatedInvite.expiresAt };
|
||||
|
||||
const inviteToken = createInviteToken(parsedInput.inviteId, updatedInvite.email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return { inviteToken: encodeURIComponent(inviteToken) };
|
||||
}
|
||||
const inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return { inviteToken: encodeURIComponent(inviteToken) };
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteMembershipAction = z.object({
|
||||
userId: ZId,
|
||||
@@ -191,6 +219,7 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
|
||||
invite?.creator?.name ?? "",
|
||||
updatedInvite.name ?? ""
|
||||
);
|
||||
|
||||
return updatedInvite;
|
||||
}
|
||||
)
|
||||
|
||||
+2
@@ -80,6 +80,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
if (createInviteTokenResponse?.data) {
|
||||
setShareInviteToken(createInviteTokenResponse.data.inviteToken);
|
||||
setShowShareInviteModal(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createInviteTokenResponse);
|
||||
toast.error(errorMessage);
|
||||
@@ -99,6 +100,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
});
|
||||
if (resendInviteResponse?.data) {
|
||||
toast.success(t("environments.settings.general.invitation_sent_once_more"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(resendInviteResponse);
|
||||
toast.error(errorMessage);
|
||||
|
||||
+2
-2
@@ -47,8 +47,8 @@ export const MembersInfo = ({
|
||||
<Badge type="gray" text="Expired" size="tiny" data-testid="expired-badge" />
|
||||
) : (
|
||||
<TooltipRenderer
|
||||
tooltipContent={`${t("environments.settings.general.invited_on", {
|
||||
date: getFormattedDateTimeString(member.createdAt),
|
||||
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
|
||||
date: getFormattedDateTimeString(member.expiresAt),
|
||||
})}`}>
|
||||
<Badge type="warning" text="Pending" size="tiny" />
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -9,7 +9,14 @@ import {
|
||||
} from "@formbricks/types/errors";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { TInvitee } from "../types/invites";
|
||||
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
|
||||
import {
|
||||
deleteInvite,
|
||||
getInvite,
|
||||
getInvitesByOrganizationId,
|
||||
inviteUser,
|
||||
refreshInviteExpiration,
|
||||
resendInvite,
|
||||
} from "./invite";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -46,32 +53,129 @@ const mockInvite: Invite = {
|
||||
teamIds: [],
|
||||
};
|
||||
|
||||
describe("resendInvite", () => {
|
||||
describe("refreshInviteExpiration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
test("returns email and name if invite exists", async () => {
|
||||
vi.mocked(prisma.invite.findUnique).mockResolvedValue({ ...mockInvite, creator: {} });
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue({ ...mockInvite, organizationId: "org-1" });
|
||||
const result = await resendInvite("invite-1");
|
||||
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
|
||||
|
||||
test("updates expiresAt to approximately 7 days from now", async () => {
|
||||
const now = Date.now();
|
||||
const expectedExpiresAt = new Date(now + 1000 * 60 * 60 * 24 * 7);
|
||||
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue({
|
||||
...mockInvite,
|
||||
expiresAt: expectedExpiresAt,
|
||||
});
|
||||
|
||||
const result = await refreshInviteExpiration("invite-1");
|
||||
|
||||
expect(prisma.invite.update).toHaveBeenCalledWith({
|
||||
where: { id: "invite-1" },
|
||||
data: {
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 - 1000);
|
||||
expect(result.expiresAt.getTime()).toBeLessThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 + 1000);
|
||||
});
|
||||
test("throws ResourceNotFoundError if invite not found", async () => {
|
||||
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
test("throws ResourceNotFoundError if invite not found (P2025)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
test("throws DatabaseError on prisma error", async () => {
|
||||
|
||||
test("throws DatabaseError on other prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error if non-prisma error", async () => {
|
||||
const error = new Error("db");
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(error);
|
||||
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow("db");
|
||||
});
|
||||
|
||||
test("returns full invite object with all fields", async () => {
|
||||
const updatedInvite = {
|
||||
...mockInvite,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
};
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
|
||||
|
||||
const result = await refreshInviteExpiration("invite-1");
|
||||
|
||||
expect(result).toEqual(updatedInvite);
|
||||
expect(result.id).toBe("invite-1");
|
||||
expect(result.email).toBe("test@example.com");
|
||||
expect(result.name).toBe("Test User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resendInvite", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns email and name after updating expiration", async () => {
|
||||
const updatedInvite = {
|
||||
...mockInvite,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
};
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
|
||||
|
||||
const result = await resendInvite("invite-1");
|
||||
|
||||
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
|
||||
expect(prisma.invite.update).toHaveBeenCalledWith({
|
||||
where: { id: "invite-1" },
|
||||
data: {
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("calls refreshInviteExpiration helper", async () => {
|
||||
const updatedInvite = {
|
||||
...mockInvite,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
};
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
|
||||
|
||||
await resendInvite("invite-1");
|
||||
|
||||
expect(prisma.invite.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError if invite not found", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error if prisma error", async () => {
|
||||
test("throws error if non-prisma error", async () => {
|
||||
const error = new Error("db");
|
||||
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(error);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow("db");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,44 +13,21 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
|
||||
|
||||
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
|
||||
export const refreshInviteExpiration = async (inviteId: string): Promise<Invite> => {
|
||||
try {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
|
||||
const updatedInvite = await prisma.invite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
where: { id: inviteId },
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
email: updatedInvite.email,
|
||||
name: updatedInvite.name,
|
||||
};
|
||||
return updatedInvite;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2025") {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
@@ -58,6 +35,16 @@ export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "emai
|
||||
}
|
||||
};
|
||||
|
||||
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
|
||||
// Refresh expiration and return the updated invite (single query)
|
||||
const updatedInvite = await refreshInviteExpiration(inviteId);
|
||||
|
||||
return {
|
||||
email: updatedInvite.email,
|
||||
name: updatedInvite.name,
|
||||
};
|
||||
};
|
||||
|
||||
export const getInvitesByOrganizationId = reactCache(
|
||||
async (organizationId: string, page?: number): Promise<TInvite[]> => {
|
||||
validateInputs([organizationId, z.string()], [page, z.number().optional()]);
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BlocksIcon,
|
||||
BrushIcon,
|
||||
Cable,
|
||||
LanguagesIcon,
|
||||
ListChecksIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { BlocksIcon, BrushIcon, 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";
|
||||
@@ -77,13 +69,6 @@ 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} />;
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from "@/modules/survey/editor/lib/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { isEndingCardValid, isWelcomeCardValid, validateSurveyElementsInBatch } from "../lib/validation";
|
||||
import { isEndingCardValid, isWelcomeCardValid, validateElement } from "../lib/validation";
|
||||
|
||||
interface ElementsViewProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -211,35 +211,6 @@ export const ElementsView = ({
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!invalidElements) return;
|
||||
let updatedInvalidElements: string[] = [...invalidElements];
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
|
||||
if (!updatedInvalidElements.includes("start")) {
|
||||
updatedInvalidElements = [...updatedInvalidElements, "start"];
|
||||
}
|
||||
} else {
|
||||
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== "start");
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
localSurvey.endings.forEach((ending) => {
|
||||
if (!isEndingCardValid(ending, surveyLanguages)) {
|
||||
if (!updatedInvalidElements.includes(ending.id)) {
|
||||
updatedInvalidElements = [...updatedInvalidElements, ending.id];
|
||||
}
|
||||
} else {
|
||||
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== ending.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
|
||||
setInvalidElements(updatedInvalidElements);
|
||||
}
|
||||
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidElements, setInvalidElements]);
|
||||
|
||||
const updateElement = (elementIdx: number, updatedAttributes: any) => {
|
||||
// Get element ID from current elements array (for validation)
|
||||
const element = elements[elementIdx];
|
||||
@@ -250,7 +221,6 @@ export const ElementsView = ({
|
||||
|
||||
// Track side effects that need to happen after state update
|
||||
let newActiveElementId: string | null = null;
|
||||
let invalidElementsUpdate: string[] | null = null;
|
||||
|
||||
// Use functional update to ensure we work with the latest state
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
@@ -296,13 +266,6 @@ export const ElementsView = ({
|
||||
const initialElementId = elementId;
|
||||
updatedSurvey = handleElementLogicChange(updatedSurvey, initialElementId, elementLevelAttributes.id);
|
||||
|
||||
// Track side effects to apply after state update
|
||||
if (invalidElements?.includes(initialElementId)) {
|
||||
invalidElementsUpdate = invalidElements.map((id) =>
|
||||
id === initialElementId ? elementLevelAttributes.id : id
|
||||
);
|
||||
}
|
||||
|
||||
// Track new active element ID
|
||||
newActiveElementId = elementLevelAttributes.id;
|
||||
|
||||
@@ -344,9 +307,6 @@ export const ElementsView = ({
|
||||
});
|
||||
|
||||
// Apply side effects after state update is queued
|
||||
if (invalidElementsUpdate) {
|
||||
setInvalidElements(invalidElementsUpdate);
|
||||
}
|
||||
if (newActiveElementId) {
|
||||
setActiveElementId(newActiveElementId);
|
||||
}
|
||||
@@ -764,23 +724,67 @@ export const ElementsView = ({
|
||||
setLocalSurvey(result.data);
|
||||
};
|
||||
|
||||
//useEffect to validate survey when changes are made to languages
|
||||
useEffect(() => {
|
||||
if (!invalidElements) return;
|
||||
let updatedInvalidElements: string[] = invalidElements;
|
||||
// Validate each element
|
||||
elements.forEach((element) => {
|
||||
updatedInvalidElements = validateSurveyElementsInBatch(
|
||||
element,
|
||||
updatedInvalidElements,
|
||||
surveyLanguages
|
||||
);
|
||||
});
|
||||
// Validate survey when changes are made to languages or elements
|
||||
// using set for O(1) lookup
|
||||
useEffect(
|
||||
() => {
|
||||
if (!invalidElements) return;
|
||||
|
||||
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
|
||||
setInvalidElements(updatedInvalidElements);
|
||||
}
|
||||
}, [elements, surveyLanguages, invalidElements, setInvalidElements]);
|
||||
const currentInvalidSet = new Set(invalidElements);
|
||||
let hasChanges = false;
|
||||
|
||||
// Validate each element
|
||||
elements.forEach((element) => {
|
||||
const isValid = validateElement(element, surveyLanguages);
|
||||
if (isValid) {
|
||||
if (currentInvalidSet.has(element.id)) {
|
||||
currentInvalidSet.delete(element.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (!currentInvalidSet.has(element.id)) {
|
||||
currentInvalidSet.add(element.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
|
||||
if (!currentInvalidSet.has("start")) {
|
||||
currentInvalidSet.add("start");
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (currentInvalidSet.has("start")) {
|
||||
currentInvalidSet.delete("start");
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
localSurvey.endings.forEach((ending) => {
|
||||
if (!isEndingCardValid(ending, surveyLanguages)) {
|
||||
if (!currentInvalidSet.has(ending.id)) {
|
||||
currentInvalidSet.add(ending.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (currentInvalidSet.has(ending.id)) {
|
||||
currentInvalidSet.delete(ending.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setInvalidElements(Array.from(currentInvalidSet));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
elements,
|
||||
surveyLanguages,
|
||||
invalidElements,
|
||||
setInvalidElements,
|
||||
localSurvey.welcomeCard,
|
||||
localSurvey.endings,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
||||
@@ -791,7 +795,7 @@ export const ElementsView = ({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeElementId, setActiveElementId]);
|
||||
}, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
||||
@@ -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 { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||
import { TMultipleChoiceOptionDisplayType, 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,6 +21,7 @@ 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 {
|
||||
@@ -75,6 +76,11 @@ 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) {
|
||||
@@ -382,6 +388,20 @@ 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)}
|
||||
|
||||
@@ -86,6 +86,7 @@ export const SurveyEditor = ({
|
||||
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
||||
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
|
||||
const [invalidElements, setInvalidElements] = useState<string[] | null>(null);
|
||||
|
||||
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
|
||||
const surveyEditorRef = useRef(null);
|
||||
const [localProject, setLocalProject] = useState<Project>(project);
|
||||
|
||||
@@ -56,9 +56,25 @@ export const CustomScriptsInjector = ({
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Copy inline script content
|
||||
// Copy inline script content with error handling
|
||||
if (script.textContent) {
|
||||
newScript.textContent = script.textContent;
|
||||
// Wrap inline scripts in try-catch to prevent user script errors from breaking the survey
|
||||
newScript.textContent = `
|
||||
(function() {
|
||||
try {
|
||||
${script.textContent}
|
||||
} catch (error) {
|
||||
console.warn('[Formbricks] Error in custom script:', error);
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// Add error handler for external scripts
|
||||
if (script.src) {
|
||||
newScript.onerror = (error) => {
|
||||
console.warn("[Formbricks] Error loading external script:", script.src, error);
|
||||
};
|
||||
}
|
||||
|
||||
document.head.appendChild(newScript);
|
||||
|
||||
@@ -68,6 +68,7 @@ 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",
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,222 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user