Compare commits

..

64 Commits

Author SHA1 Message Date
Dhruwang Jariwala 880385029d fix: [Backport] restore logout and profile access for billing-role users (#8136) (#8137)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:03:16 +02:00
Dhruwang Jariwala 062a59efed fix: harden Helm release secret lookups (#8119) 2026-05-22 17:27:29 +05:30
Bhagya Amarasinghe b577c151c8 fix: harden Helm release secret lookups 2026-05-22 17:09:07 +05:30
Dhruwang Jariwala f39f12ec1c feat: [Backport] cascade delete Hub feedback records on org deletion (#8055) (#8116) 2026-05-22 16:22:43 +05:30
Dhruwang 01fb38cff8 chore: bump Hub image to 0.4.0 for tenant-scoped delete endpoint
Cascade delete in deleteOrganization calls DELETE /v1/tenants/{tenant_id}/data,
which is first available in Hub 0.4.0. Bumps the dev-compose default tag and the
Helm chart digest/tag so deployments don't silently land on the older Hub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:50 +05:30
Dhruwang 71e57947b9 fix: switch Hub purge to DELETE /v1/tenants/{tenant_id}/data
Hub#77 ships the proper tenant-data purge endpoint that nukes feedback
records, derived embeddings, and webhooks in one idempotent call. Swap
the placeholder bulkDelete cast for a direct client.delete<>() against
the new path and surface the per-resource counts.

Renames deleteFeedbackRecordsByTenant → deleteHubTenantData to reflect
that it deletes more than feedback records. Org-deletion call site and
tests updated accordingly. Best-effort error handling unchanged.
2026-05-22 16:08:49 +05:30
Dhruwang 5f6d6d53b2 feat: cascade delete Hub feedback records on org deletion (ENG-973)
Add a tenant-scoped purge wrapper in the Hub gateway and call it for
each FeedbackDirectory after an organization is deleted, so Hub records
do not become orphaned when the local cascade clears the directory rows.

Depends on a Hub-side change to accept a tenant-only bulkDelete payload;
the call is best-effort and failures are logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:08:49 +05:30
Anshuman Pandey b79758ee49 fix: [Backport] json payload limit (#8115)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-05-22 12:55:14 +04:00
Bhagya Amarasinghe 25b0b89e86 fix: use Valkey for bundled Helm Redis (backport #8092) (#8105) 2026-05-22 11:39:11 +05:30
Bhagya Amarasinghe f228ce7eb6 fix: address Helm Valkey review comments 2026-05-22 11:36:36 +05:30
Bhagya Amarasinghe 67ae13a61a fix: use Valkey for bundled Helm Redis 2026-05-22 11:36:31 +05:30
Johannes 70e72ab0de fix: backport removal of isAIDataAnalysisEnabled to v5 (#8112) 2026-05-21 20:38:04 +02:00
Johannes 07d1d918ba fix: backport CSAT and CES summary filter icons to 5.0 (#8058)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 17:56:52 +02:00
Bhagya Amarasinghe a98a0a8e73 fix: pin DNS and block redirects on webhook delivery (backport #8095) (#8106) 2026-05-21 19:10:59 +05:30
Bhagya Amarasinghe a5a67a05de fix: order Helm Hub migrations after Prisma (backport #8104) (#8107) 2026-05-21 19:10:32 +05:30
Bhagya Amarasinghe 4876e107f8 fix: order Helm Hub migrations after Prisma 2026-05-21 18:33:44 +05:30
Bhagya Amarasinghe 5db616ac07 fix(webhooks): ignore dispatcher cleanup failures 2026-05-21 18:33:25 +05:30
pandeymangg fa1ccdb2c3 pin DNS and block redirects on webhook delivery 2026-05-21 18:33:25 +05:30
Dhruwang Jariwala 6183ab4744 fix: backport #8101 reserved contact keys and segment errors to 5.0 (#8103) 2026-05-21 17:12:03 +05:30
Dhruwang Jariwala 431a3d8a76 fix: allow enterprise oauth display names (#8100) 2026-05-21 16:30:16 +05:30
Dhruwang Jariwala 91ab958379 fix: chart date range type switch + presets include today (backport #8096) (#8097) 2026-05-21 16:26:59 +05:30
Dhruwang Jariwala e5df832653 fix: [Backport] adds close button on response error screen (#8098) 2026-05-21 16:26:09 +05:30
Johannes 0909c38eb1 code rabbit comments
(cherry picked from commit 8fb2287ae7)
2026-05-21 12:46:03 +02:00
Johannes 08b0d95295 fix: reserve future contact keys and improve segment errors (ENG-1037, ENG-994)
Block creation of reserved safe-identifier contact keys across API, SDK, and CSV flows to prevent collisions ahead of the v5.1 migration, and surface clearer personal-link/segment validation errors instead of unknown failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 9da9f1582c)
2026-05-21 12:46:03 +02:00
pandeymangg a49e989413 refactor: replace custom functions with date-fns 2026-05-21 15:57:30 +05:30
Cursor Agent 9445b2f482 fix: restrict user name whitespace 2026-05-21 10:22:24 +00:00
Cursor Agent f07d832516 test: anonymize oauth display name fixture 2026-05-21 10:22:24 +00:00
Cursor Agent e615c692a9 fix: allow enterprise oauth display names
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-21 10:22:24 +00:00
Dhruwang Jariwala a9a910d15c fix: AI translation rich-text editors stay empty (backport #8084) (#8091) 2026-05-21 12:09:33 +02:00
pandeymangg c425e7aff4 adds close button on response error screen 2026-05-21 15:18:35 +05:30
Dhruwang a83a54a24a fix: chart date range type switch + presets include today (ENG-1034, ENG-1035)
ENG-1034: in the chart editor, switching the date range type from Custom
back to Preset would leave Update chart disabled because the toggle only
updated local UI state and never propagated the change to the parent
config. hasConfigChanged compared two queries that still held the old
custom [Date, Date] range, so it stayed false. The new
handleDateRangeTypeChange seats the parent timeDimension.dateRange to
match the chosen type (preset string or [start, end] tuple) on toggle
so hasConfigChanged can re-evaluate.

ENG-1035: Cube v1.6.6's native "last 7 days" / "last 30 days" / "this
month" presets anchor to end-of-yesterday and exclude today, which
diverges from every other analytics tool (GA, Mixpanel, PostHog, ...).
Added expandPresetDateRanges that rewrites known preset strings to
explicit inclusive [YYYY-MM-DD, YYYY-MM-DD] tuples at the Cube boundary
only. Stored client queries keep the preset string so hasConfigChanged
stays consistent and saved charts round-trip back to the Preset UI.
Unknown preset strings pass through unchanged.
2026-05-21 14:54:57 +05:30
Dhruwang Jariwala f7890eaec3 fix: backport billing-only settings access to 5.0 (#8090) 2026-05-21 13:02:25 +05:30
Dhruwang Jariwala 8cd3187eff fix: backport settings back navigation to 5.0 (#8089) 2026-05-21 13:02:09 +05:30
Dhruwang Jariwala 83bccc7ded fix: backport Cube API secret Helm defaults to 5.0 (#8088) 2026-05-21 12:50:13 +05:30
Dhruwang Jariwala 00aa6d5247 fix: [Backport] backports removal of timestamps from client responses api (#8087) 2026-05-21 12:45:12 +05:30
Dhruwang Jariwala 0657c94ee5 fix: [Backport] excel injection backport (#8086) 2026-05-21 12:44:46 +05:30
Johannes a36cef2936 fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit c0bf2ab7cc)
2026-05-21 07:11:50 +00:00
Johannes 467af8b6ef fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
(cherry picked from commit 13c9677edd)
2026-05-21 07:09:46 +00:00
Dhruwang Jariwala d0e057eac1 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (backport #8061) (#8085) 2026-05-21 12:30:59 +05:30
Bhagya Amarasinghe ef1f5a2b12 fix: wire Cube API secret into Helm defaults
(cherry picked from commit 0e65278af7)
2026-05-21 07:00:06 +00:00
pandeymangg 770041923f backports removal of timestamps from client responses api 2026-05-21 12:14:11 +05:30
pandeymangg 3e66ff25a1 backports excel injection fix 2026-05-21 12:08:05 +05:30
Dhruwang Jariwala c979909da9 fix: show copy icon on legacy environmentId, reintroduce duplicate survey action (ENG-978, ENG-987) (#8061)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:00:28 +05:30
Dhruwang Jariwala 010d96ebcd fix: harden Helm env value rendering (backport #8070) (#8078) 2026-05-21 11:49:30 +05:30
Dhruwang Jariwala a0b3054f4a fix: [Backport] responseId client api fix (#8083) 2026-05-21 11:47:34 +05:30
Dhruwang Jariwala 02d3cd2af3 fix: update Helm chart default image tag (backport #8072) (#8081) 2026-05-21 11:17:40 +05:30
Dhruwang Jariwala 2ef4eb4345 fix: require Cube API secret in compose (backport #8071) (#8080) 2026-05-21 11:17:25 +05:30
Dhruwang Jariwala 093757b386 fix: render scheduled-plan-change description placeholders correctly (backport #8064) (#8077) 2026-05-21 11:17:10 +05:30
pandeymangg 64f8746940 responseId api fix 2026-05-21 11:13:08 +05:30
Dhruwang Jariwala 851616078a fix: gate AI chart generation on smartTools, not dataAnalysis (backport #8060) (#8076) 2026-05-21 11:01:47 +05:30
Dhruwang Jariwala 06d5313629 fix: route Manage Teams and integration OAuth callbacks to settings (backport #8059) (#8075) 2026-05-21 11:01:34 +05:30
Bhagya Amarasinghe 7834c21d39 fix: update Helm chart default image tag (#8072) 2026-05-21 10:58:09 +05:30
Bhagya Amarasinghe f98ca39035 fix: require Cube API secret in compose (#8071) 2026-05-21 10:57:57 +05:30
Bhagya Amarasinghe 48f928b1bf fix: harden Helm env value rendering (#8070) 2026-05-21 10:48:28 +05:30
Dhruwang Jariwala f5dfb4739c fix: render scheduled-plan-change description placeholders correctly (#8064)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:53 +05:30
Anshuman Pandey 5a1fc01388 fix: [Backport] client environment api sdk fixes (#8074) 2026-05-21 09:16:53 +04:00
Dhruwang Jariwala 77a39c13fa fix: gate AI chart generation on smartTools, not dataAnalysis (ENG-1001) (#8060)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:46:17 +05:30
Dhruwang Jariwala 5a12539c75 fix: route Manage Teams and integration OAuth callbacks to settings (ENG-988) (#8059)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:45:43 +05:30
Dhruwang Jariwala 74e0fba757 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0] (#8069) 2026-05-21 10:25:56 +05:30
Dhruwang Jariwala d9c2756185 fix: sync chart Cube feedback schema (backport #8057 to release/5.0) (#8066) 2026-05-21 10:20:53 +05:30
Matti Nannt 88ad5c8625 fix: scope display contact lookup to workspace (ENG-818) [backport release/5.0]
doesContactExist checked contact existence by id only, allowing a caller
to bind a display in one workspace to a contact from a different workspace.
Add workspaceId filter and rename to doesContactExistInWorkspace.

Backport of #8048 to release/5.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:27:48 +02:00
Bhagya Amarasinghe 610beee7eb fix: sync chart Cube feedback schema
Backport of #8057 to release/5.0 (excludes test file changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:55:59 +05:30
Javi Aguilar db0e2bb105 fix: add CSAT and CES summary filter icons (backport #8056) (#8063) 2026-05-20 15:05:47 +02:00
Johannes 419ceef413 fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 14:38:23 +02:00
522 changed files with 1619 additions and 3515 deletions
+6 -6
View File
@@ -53,7 +53,7 @@ function {QuestionType}({
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
@@ -63,11 +63,11 @@ function {QuestionType}({
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
-1
View File
@@ -5,7 +5,6 @@
"type": "module",
"scripts": {
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App.tsx";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
@@ -120,31 +120,30 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<Building2Icon className="size-4" strokeWidth={1.5} />
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && <Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline size-4" strokeWidth={1.5} />
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="size-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && organizationLoadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
<button
type="button"
onClick={() => {
setOrganizationLoadError(null);
setOrganizations([]);
@@ -172,7 +171,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
@@ -198,7 +197,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
@@ -211,7 +210,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
className="flex w-full items-center">
<DropdownMenuItem>
<link.icon className="mr-2 size-4" strokeWidth={1.5} />
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
@@ -226,7 +225,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
callbackUrl: "/auth/login",
});
}}
icon={<LogOutIcon className="mr-2 size-4" strokeWidth={1.5} />}>
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -46,7 +46,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isMembershipPending={isMembershipPending}
/>
</div>
<div className="flex h-full flex-col items-center justify-center gap-y-12">
<div className="flex h-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.landing.no_workspaces_warning_title")}
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
@@ -53,7 +53,7 @@ const Page = async (props: ChannelPageProps) => {
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.workspaces.new.channel.channel_select_title")}
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
@@ -65,7 +65,7 @@ const Page = async (props: ChannelPageProps) => {
variant="ghost"
asChild>
<Link href={"/"}>
<XIcon className="size-7" strokeWidth={1.5} />
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -50,7 +50,7 @@ const Page = async (props: ModePageProps) => {
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{workspaces.length >= 1 && (
@@ -59,7 +59,7 @@ const Page = async (props: ModePageProps) => {
variant="ghost"
asChild>
<Link href={"/"}>
<XIcon className="size-7" strokeWidth={1.5} />
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -125,8 +125,8 @@ export const WorkspaceSettings = ({
}));
return (
<div className="mt-6 flex w-5/6 gap-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col gap-y-4">
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
@@ -224,7 +224,7 @@ export const WorkspaceSettings = ({
</FormProvider>
</div>
<div className="relative flex w-1/2 flex-col items-center justify-center gap-y-2 rounded-lg border bg-slate-200 p-6 shadow">
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
{logoUrl && (
<Image
src={logoUrl}
@@ -93,7 +93,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
@@ -115,7 +115,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
variant="ghost"
asChild>
<Link href={"/"}>
<XIcon className="size-7" strokeWidth={1.5} />
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -27,7 +27,7 @@ export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContain
description={option.description}
loading={option.isLoading || false}>
<div className="flex flex-col items-center">
<Icon className="size-16 text-slate-600" strokeWidth={1} />
<Icon className="h-16 w-16 text-slate-600" strokeWidth={1} />
{option.iconText && (
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
{option.iconText}
@@ -43,9 +43,9 @@ export const ConnectWithFormbricks = ({
}, []);
return (
<div className="mt-6 flex w-5/6 flex-col items-center gap-y-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-full gap-x-10">
<div className="flex w-1/2 flex-col gap-y-4">
<div className="mt-6 flex w-5/6 flex-col items-center space-y-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
workspaceId={workspaceId}
publicDomain={publicDomain}
@@ -66,10 +66,10 @@ export const ConnectWithFormbricks = ({
</p>
</div>
) : (
<div className="flex animate-pulse flex-col items-center gap-y-4">
<span className="relative flex size-10">
<div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex size-10 rounded-full bg-slate-500"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("workspace.connect.waiting_for_your_signal")}
@@ -134,7 +134,7 @@ export const OnboardingSetupInstructions = ({
</CodeBlock>
</div>
<div className="mt-4 flex justify-between gap-x-2">
<div className="mt-4 flex justify-between space-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant={appSetupCompleted ? "secondary" : "default"}
@@ -45,7 +45,7 @@ const Page = async (props: ConnectPageProps) => {
variant="ghost"
asChild>
<Link href={`/workspaces/${params.workspaceId}`}>
<XIcon className="size-7" strokeWidth={1.5} />
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
</div>
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center gap-y-12">
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
@@ -47,7 +47,7 @@ const Page = async (props: XMTemplatePageProps) => {
variant="ghost"
asChild>
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="size-7" strokeWidth={1.5} />
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
@@ -238,10 +238,7 @@ export const MainNavigation = ({
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={onRetry}
className="text-xs text-slate-600 underline hover:text-slate-800">
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
@@ -574,275 +571,285 @@ export const MainNavigation = ({
</div>
)}
{!isSettingsMode && (
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center gap-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 size-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
<div>
{!isSettingsMode && (
<>
{/* New Version Available */}
{!isCollapsed &&
isOwnerOrManager &&
latestVersion &&
!isFormbricksCloud &&
!isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed &&
isFormbricksCloud &&
trialDaysRemaining !== null &&
(newTrialBannerVariant === "new-trial-banner" ? (
<TrialBannerNew
trialDaysRemaining={trialDaysRemaining}
planName={organization.billing.stripe?.plan ?? "pro"}
responseCount={responseCount}
responseLimit={organization.billing.limits.monthly.responses}
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
/>
) : (
<Link
href={`/workspaces/${workspace.id}/settings/organization/billing`}
className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
))}
</>
)}
<div className="flex flex-col">
{!isSettingsMode && (
<>
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger
asChild
id="workspaceDropdownTrigger"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{workspace.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces &&
!isInitialWorkspacesLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setWorkspaces([]);
},
t("common.try_again")
)}
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === workspace.id}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleWorkspaceCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
}
className="cursor-pointer">
<Cog className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{/* Trial Days Remaining */}
{!isCollapsed &&
isFormbricksCloud &&
trialDaysRemaining !== null &&
(newTrialBannerVariant === "new-trial-banner" ? (
<TrialBannerNew
trialDaysRemaining={trialDaysRemaining}
planName={organization.billing.stripe?.plan ?? "pro"}
responseCount={responseCount}
responseLimit={organization.billing.limits.monthly.responses}
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
/>
) : (
<Link
href={`/workspaces/${workspace.id}/settings/organization/billing`}
className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
))}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger
asChild
id="workspaceDropdownTrigger"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="size-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{workspace.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline size-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="size-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces &&
!isInitialWorkspacesLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setWorkspaces([]);
},
t("common.try_again")
)}
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button
type="button"
aria-label={isCollapsed ? t("common.account_settings") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === workspace.id}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleWorkspaceCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
<div className="grow overflow-hidden">
<p
title={user?.email}
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
}
className="cursor-pointer">
<Cog className="mr-2 size-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</button>
</DropdownMenuTrigger>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="size-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="size-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline size-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="size-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
}
className="cursor-pointer">
<SettingsIcon className="mr-2 size-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button
type="button"
aria-label={isCollapsed ? t("common.account_settings") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p
title={user?.email}
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="size-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 size-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearWorkspaceId: true,
});
router.push(route?.url || loginUrl);
}}
icon={<LogOutIcon className="mr-2 size-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearWorkspaceId: true,
});
router.push(route?.url || loginUrl);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</aside>
)}
{openWorkspaceLimitModal && (
@@ -1,9 +1,9 @@
export const NavbarLoading = () => {
return (
<div>
<div className="flex justify-between gap-x-4 px-4 py-2">
<div className="flex justify-between space-x-4 px-4 py-2">
<div className="flex">
<div className="mx-2 size-8 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 h-8 w-8 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
@@ -11,7 +11,7 @@ export const NavbarLoading = () => {
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
</div>
<div className="flex">
<div className="mx-2 size-8 animate-pulse rounded-full bg-slate-200"></div>
<div className="mx-2 h-8 w-8 animate-pulse rounded-full bg-slate-200"></div>
<div className="mx-2 h-8 w-20 animate-pulse rounded-md bg-slate-200"></div>
</div>
</div>
@@ -195,12 +195,12 @@ const SectionHeader = ({
<DropdownMenu onOpenChange={(open) => open && onSwitcherOpen?.()}>
<DropdownMenuTrigger className="ml-auto flex min-w-0 max-w-[50%] items-center gap-1 rounded-md border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50">
<span className="truncate">{switcherName}</span>
<ChevronDownIcon className="size-3" />
<ChevronDownIcon className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px]">
{isLoadingSwitcher ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="size-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<DropdownMenuGroup className="overflow-y-auto">
@@ -374,7 +374,6 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
@@ -409,7 +408,7 @@ export const SettingsSidebarContent = ({
<div className="flex flex-col overflow-y-auto">
<div>
<SectionHeader
icon={<FoldersIcon className="size-4" />}
icon={<FoldersIcon className="h-4 w-4" />}
label={t("common.workspace")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
@@ -425,7 +424,7 @@ export const SettingsSidebarContent = ({
<div>
<SectionHeader
icon={<Building2Icon className="size-4" />}
icon={<Building2Icon className="h-4 w-4" />}
label={t("common.organization")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
@@ -441,7 +440,7 @@ export const SettingsSidebarContent = ({
<div>
<SectionHeader
icon={<UserCircleIcon className="size-4" />}
icon={<UserCircleIcon className="h-4 w-4" />}
label={t("common.account")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
@@ -124,13 +124,13 @@ export const OrganizationBreadcrumb = ({
id="organizationDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Building2Icon className="size-3" strokeWidth={1.5} />
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
<span>{organizationName}</span>
{isPending && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? (
<ChevronDownIcon className="size-3" strokeWidth={1.5} />
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronRightIcon className="size-3" strokeWidth={1.5} />
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
@@ -138,19 +138,18 @@ export const OrganizationBreadcrumb = ({
{showOrganizationDropdown && (
<>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline size-4" />
<Building2Icon className="mr-2 inline h-4 w-4" />
{t("common.choose_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="size-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
type="button"
onClick={() => {
setLoadError(null);
setOrganizations([]);
@@ -178,7 +177,7 @@ export const OrganizationBreadcrumb = ({
onClick={() => setOpenCreateOrganizationModal(true)}
className="cursor-pointer">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 size-4" />
<PlusIcon className="ml-2 h-4 w-4" />
</DropdownMenuCheckboxItem>
)}
</>
@@ -191,7 +190,7 @@ export const OrganizationBreadcrumb = ({
<DropdownMenuCheckboxItem
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
className="cursor-pointer">
<SettingsIcon className="mr-2 size-4" />
<SettingsIcon className="mr-2 h-4 w-4" />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</>
@@ -154,32 +154,31 @@ export const WorkspaceBreadcrumb = ({
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger className="flex cursor-pointer items-center gap-1 outline-none" asChild>
<div className="flex items-center gap-1">
<FoldersIcon className="size-3" strokeWidth={1.5} />
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{workspaceName}</span>
{isPending && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
<ChevronRightIcon className="size-3" strokeWidth={1.5} />
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="size-3" strokeWidth={1.5} />
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline size-4" strokeWidth={1.5} />
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingWorkspaces && (
<div className="flex items-center justify-center py-2">
<Loader2 className="size-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
type="button"
onClick={() => {
setLoadError(null);
setWorkspaces([]);
@@ -212,7 +211,7 @@ export const WorkspaceBreadcrumb = ({
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
@@ -226,7 +225,7 @@ export const WorkspaceBreadcrumb = ({
onClick={handleAddWorkspace}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 size-4" strokeWidth={1.5} />
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
@@ -237,7 +236,7 @@ export const WorkspaceBreadcrumb = ({
handleWorkspaceSettingsNavigation(`${workspaceBasePath}/settings/workspace/general`)
}
className="cursor-pointer">
<CogIcon className="mr-2 size-4" strokeWidth={1.5} />
<CogIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
@@ -1,11 +1,10 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const AccountSettingsLayout = async (
props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>
) => {
await props.params;
return <>{props.children}</>;
};
@@ -28,8 +28,8 @@ export const EditAlerts = ({
<>
{memberships.map((membership) => (
<div key={membership.organization.id}>
<div className="mb-5 grid grid-cols-6 items-center gap-x-3">
<div className="col-span-3 flex items-center gap-x-3">
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
<div className="col-span-3 flex items-center space-x-3">
<UsersIcon className="h-6 w-7 text-slate-600" />
<p className="text-sm font-medium text-slate-800">{membership.organization.name}</p>
@@ -54,9 +54,9 @@ export const EditAlerts = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="col-span-1 flex cursor-default items-center justify-center gap-x-2">
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
<span>{t("workspace.settings.notifications.every_response")}</span>
<HelpCircleIcon className="size-4 flex-shrink-0 text-slate-500" />
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
</div>
</TooltipTrigger>
<TooltipContent>
@@ -9,8 +9,8 @@ export const IntegrationsTip = () => {
const { workspace } = useWorkspace();
return (
<div>
<div className="flex max-w-4xl items-center gap-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:gap-y-0 md:text-base">
<SlackIcon className="mr-3 size-4 text-blue-400" />
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
<p className="text-sm">
{t("workspace.settings.notifications.need_slack_or_discord_notifications")}?
<a
@@ -6,6 +6,7 @@ import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/accoun
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/IntegrationsTip";
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/types";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -127,7 +128,13 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships;
};
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => {
const Page = async (props: {
params: Promise<{ workspaceId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const searchParams = await props.searchParams;
const t = await getTranslate();
const session = await getServerSession(authOptions);
@@ -18,7 +18,7 @@ export const AccountSecurity = ({ user }: AccountSecurityProps) => {
return (
<div>
<div className="flex items-center gap-x-4">
<div className="flex items-center space-x-4">
<Switch
checked={user.twoFactorEnabled}
onCheckedChange={(checked) => {
@@ -225,7 +225,7 @@ export const EditProfileDetailsForm = ({
) : (
t("common.select")
)}
<ChevronDownIcon className="size-4 text-slate-500" />
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
@@ -112,12 +112,12 @@ export const EnterpriseLicenseStatus = ({
className="shrink-0">
{isRechecking ? (
<>
<RotateCcwIcon className="mr-2 size-4 animate-spin" />
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
{t("workspace.settings.enterprise.rechecking")}
</>
) : (
<>
<RotateCcwIcon className="mr-2 size-4" />
<RotateCcwIcon className="mr-2 h-4 w-4" />
{t("workspace.settings.enterprise.recheck_license")}
</>
)}
@@ -109,7 +109,7 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 size-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}
@@ -145,7 +145,7 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
{paidFeatures.map((feature) => (
<li key={feature.title} className="flex items-center">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
<CheckIcon className="size-5 p-0.5 text-green-500 dark:text-green-400" />
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
</div>
<span className="ml-2 text-sm text-slate-500 dark:text-slate-400">{feature.title}</span>
{feature.comingSoon && (
@@ -8,8 +8,8 @@ export const SecurityListTip = () => {
const { t } = useTranslation();
return (
<div className="max-w-4xl">
<div className="flex items-center gap-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
<ShieldCheckIcon className="size-5 flex-shrink-0 text-blue-400" />
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
<p className="text-sm">
{t("workspace.settings.general.security_list_tip")}{" "}
<Link
@@ -68,7 +68,7 @@ const ElementCheckbox = ({
};
return (
<div className="my-1 flex items-center gap-x-2">
<div className="my-1 flex items-center space-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
@@ -330,7 +330,7 @@ export const AddIntegrationModal = ({
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent className="overflow-visible md:overflow-visible">
<DialogHeader>
<div className="flex items-center gap-x-2">
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
@@ -93,9 +93,9 @@ export const ManageIntegration = ({
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end gap-x-2">
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.connected_with_email", {
email: airtableIntegration.config.email,
@@ -106,7 +106,7 @@ export const ManageIntegration = ({
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 size-4" />
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("workspace.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
@@ -134,7 +134,6 @@ export const ManageIntegration = ({
{integrationData.map((data, index) => (
<button
type="button"
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
@@ -247,7 +247,7 @@ export const AddIntegrationModal = ({
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-x-2">
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
@@ -297,7 +297,7 @@ export const AddIntegrationModal = ({
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
<div key={question.id} className="my-1 flex items-center gap-x-2">
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
@@ -84,9 +84,9 @@ export const ManageIntegration = ({
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end gap-x-2">
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.connected_with_email", {
email: googleSheetIntegration.config.email,
@@ -97,7 +97,7 @@ export const ManageIntegration = ({
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 size-4" />
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("workspace.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
@@ -132,7 +132,6 @@ export const ManageIntegration = ({
{integrationArray.map((data, index) => {
return (
<button
type="button"
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
@@ -277,7 +277,7 @@ export const AddIntegrationModal = ({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start gap-x-2">
<div className="mb-4 flex items-start space-x-2">
<div className="relative size-8">
<Image
fill
@@ -68,9 +68,9 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end gap-x-2">
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
@@ -81,7 +81,7 @@ export const ManageIntegration = ({
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 size-4" />
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("workspace.integrations.notion.update_connection")}
</Button>
</TooltipTrigger>
@@ -113,7 +113,6 @@ export const ManageIntegration = ({
{integrationArray.map((data, index) => {
return (
<button
type="button"
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
@@ -190,7 +190,7 @@ export const MappingRow = ({
elem={mapping[idx].element}
t={t}
/>
<div className="flex w-full items-center gap-x-2">
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
<div className="max-w-full flex-1">
<DropdownSelector
@@ -212,7 +212,7 @@ export const MappingRow = ({
/>
</div>
</div>
<div className="flex gap-x-2">
<div className="flex space-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow} type="button">
<TrashIcon />
@@ -221,7 +221,7 @@ export const AddChannelMappingModal = ({
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-x-2">
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
@@ -255,7 +255,7 @@ export const AddChannelMappingModal = ({
target="_blank"
className="text-xs">
<Button variant="ghost" size="sm" className="my-2" type="button">
<CircleHelpIcon className="size-4" />
<CircleHelpIcon className="h-4 w-4" />
{t("workspace.integrations.slack.dont_see_your_channel")}
</Button>
</Link>
@@ -290,7 +290,7 @@ export const AddChannelMappingModal = ({
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((element) => (
<div key={element.id} className="my-1 flex items-center gap-x-2">
<div key={element.id} className="my-1 flex items-center space-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
@@ -71,7 +71,7 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<div className="mb-4 flex w-full items-center justify-between gap-x-4">
<div className="mb-4 flex w-full items-center justify-between space-x-4">
<p className="text-amber-700">
<Trans
i18nKey="workspace.integrations.slack.slack_reconnect_button_description"
@@ -83,9 +83,9 @@ export const ManageIntegration = ({
</Button>
</div>
)}
<div className="flex w-full justify-end gap-x-4">
<div className="flex w-full justify-end space-x-4">
<div className="mr-6 flex items-center">
<span className="mr-4 size-4 rounded-full bg-green-600"></span>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("workspace.integrations.slack.connected_with_team", {
team: slackIntegration.config.key.team?.name,
@@ -119,7 +119,6 @@ export const ManageIntegration = ({
{integrationArray.map((data, index) => {
return (
<button
type="button"
key={`${index}-${data.surveyName}-${data.channelName}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
onClick={() => {
@@ -11,7 +11,7 @@ export const EmptyAppSurveys = () => {
const { workspace } = useWorkspace();
return (
<div className="flex w-full items-center justify-center gap-8 bg-slate-100 py-12">
<div className="flex size-20 items-center justify-center rounded-full border border-slate-200 bg-white">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-slate-200 bg-white">
<Unplug size={48} className="text-amber-500" absoluteStrokeWidth />
</div>
@@ -24,7 +24,7 @@ export const SurveyAnalysisNavigation = ({ survey, activeId }: SurveyAnalysisNav
{
id: "summary",
label: t("common.summary"),
icon: <PresentationIcon className="size-5" />,
icon: <PresentationIcon className="h-5 w-5" />,
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
@@ -34,7 +34,7 @@ export const SurveyAnalysisNavigation = ({ survey, activeId }: SurveyAnalysisNav
{
id: "responses",
label: t("common.responses"),
icon: <InboxIcon className="size-5" />,
icon: <InboxIcon className="h-5 w-5" />,
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
@@ -38,7 +38,7 @@ const ResponseTableCellComponent = ({
aria-label="Expand response"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
onClick={handleCellClick}>
<Maximize2Icon className="size-4" />
<Maximize2Icon className="h-4 w-4" />
</button>
);
@@ -47,8 +47,8 @@ const getElementColumnsData = (
const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP[elementType]}</span>
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
@@ -85,8 +85,8 @@ const getElementColumnsData = (
header: () => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
<span className="truncate">
{getTextContent(getLocalizedValue(element.headline, "default")) +
" - " +
@@ -112,8 +112,8 @@ const getElementColumnsData = (
header: () => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP["address"]}</span>
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["address"]}</span>
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
</div>
</div>
@@ -135,8 +135,8 @@ const getElementColumnsData = (
header: () => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
</div>
</div>
@@ -202,8 +202,8 @@ const getElementColumnsData = (
accessorKey: "ELEMENT_" + element.id,
header: () => (
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{ELEMENTS_ICON_MAP[element.type]}</span>
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[element.type]}</span>
<span className="truncate">
{getTextContent(
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
@@ -241,8 +241,8 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
metadataColumns.push({
accessorKey: "METADATA_" + label,
header: () => (
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
</div>
),
@@ -290,7 +290,7 @@ export const generateResponseTableColumns = (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>
<CircleHelpIcon className="size-3 text-slate-500" strokeWidth={1.5} />
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
</TooltipTrigger>
<TooltipContent side="bottom" className="space-x-1 font-normal">
<span>{t("workspace.surveys.responses.how_to_identify_users")}</span>
@@ -363,7 +363,7 @@ export const generateResponseTableColumns = (
<ResponseBadges
items={tagsArray.map((tag) => ({ value: tag }))}
isExpanded={isExpanded}
icon={<TagIcon className="size-4 text-slate-500" />}
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
showId={false}
/>
);
@@ -375,8 +375,8 @@ export const generateResponseTableColumns = (
return {
accessorKey: "VARIABLE_" + variable.id,
header: () => (
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">{VARIABLES_ICON_MAP[variable.type]}</span>
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
<span className="truncate">{variable.name}</span>
</div>
),
@@ -394,9 +394,9 @@ export const generateResponseTableColumns = (
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">
<EyeOffIcon className="size-4" />
<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>
@@ -416,9 +416,9 @@ export const generateResponseTableColumns = (
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
accessorKey: "verifiedEmail",
header: () => (
<div className="flex items-center gap-x-2 overflow-hidden">
<span className="size-4">
<MailIcon className="size-4" />
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<MailIcon className="h-4 w-4" />
</span>
<span className="truncate">{t("common.verified_email")}</span>
</div>
@@ -25,9 +25,9 @@ export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProp
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="size-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="size-4" />;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
@@ -36,8 +36,8 @@ export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProp
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
@@ -27,9 +27,9 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="size-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="size-4" />;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
@@ -38,8 +38,8 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
@@ -49,7 +49,7 @@ export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryPr
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
{t("workspace.surveys.summary.csat_satisfied", {
@@ -24,16 +24,16 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
additionalInfo={
<>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 size-4" />
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.impressionCount} ${t("common.impressions")}`}
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 size-4" />
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.clickCount} ${t("common.clicks")}`}
</div>
{!elementSummary.element.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 size-4" />
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.skipCount} ${t("common.skips")}`}
</div>
)}
@@ -42,7 +42,7 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex gap-x-1">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">CTR</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
@@ -20,7 +20,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex gap-x-1">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
@@ -36,7 +36,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex gap-x-1">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
@@ -22,7 +22,7 @@ export const ClickableBarSegment = ({
return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className={className} style={style} onClick={onClick}>
<button className={className} style={style} onClick={onClick}>
{children}
</button>
</TooltipTrigger>
@@ -41,7 +41,6 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
{summaryItems.map((summaryItem) => {
return (
<button
type="button"
className="group w-full cursor-pointer"
key={summaryItem.title}
onClick={() =>
@@ -54,7 +53,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex gap-x-1">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{summaryItem.title}
</p>
@@ -39,15 +39,15 @@ export const ElementSummaryHeader = ({
)}
</h3>
</div>
<div className="flex gap-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{elementType && <elementType.icon className="mr-2 size-4" />}
{elementType && <elementType.icon className="mr-2 h-4 w-4" />}
{elementType ? elementType.label : t("workspace.surveys.summary.unknown_question_type")}{" "}
{t("common.question")}
</div>
{showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 size-4" />
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
)}
@@ -83,7 +83,7 @@ export const FileUploadSummary = ({ elementSummary, survey, locale }: FileUpload
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
@@ -34,13 +34,13 @@ export const HiddenFieldsSummary = ({ elementSummary, locale }: HiddenFieldsSumm
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{elementSummary.id}</h3>
</div>
<div className="flex gap-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<MessageSquareTextIcon className="mr-2 size-4" />
<MessageSquareTextIcon className="mr-2 h-4 w-4" />
Hidden Field
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 size-4" />
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
</div>
@@ -76,7 +76,6 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
elementSummary.data[rowIndex].totalResponsesForRow
)}>
<button
type="button"
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
onClick={() =>
@@ -74,7 +74,7 @@ export const MultipleChoiceSummary = ({
additionalInfo={
elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 size-4" />
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
@@ -102,13 +102,13 @@ export const MultipleChoiceSummary = ({
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</div>
<div className="flex w-full gap-x-2">
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
</p>
@@ -150,7 +150,7 @@ export const MultipleChoiceSummary = ({
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center gap-x-4 pl-6 font-medium text-slate-900">
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
@@ -85,7 +85,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
@@ -105,10 +105,10 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="size-4" />}>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="size-4" />}>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
@@ -119,13 +119,12 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<div className="space-y-5 text-sm md:text-base">
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex gap-x-1">
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
@@ -180,7 +179,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center gap-x-1">
<div className="mb-1 flex items-center space-x-1">
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
@@ -36,7 +36,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
additionalInfo={
elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 size-4" />
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
@@ -60,7 +60,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
@@ -72,7 +72,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div>
<div className="self-end">{choiceId && <IdBadge id={choiceId} />}</div>
</div>
<div className="flex w-full gap-x-2">
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
</p>
@@ -26,14 +26,14 @@ export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps)
return (
<div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between gap-x-2 sm:justify-normal">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<div className="flex w-full items-center">
<div className="flex items-center gap-x-2">
<div className="flex items-center space-x-2">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
{choiceId && <IdBadge id={choiceId} />}
</div>
<span className="ml-auto flex items-center gap-x-1">
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
</span>
@@ -55,10 +55,10 @@ export const RatingLikeSummary = ({
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="size-4" />}>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="size-4" />}>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
@@ -158,7 +158,6 @@ export const RatingLikeSummary = ({
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
type="button"
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
@@ -170,7 +169,7 @@ export const RatingLikeSummary = ({
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center gap-x-1">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={elementSummary.element.scale}
@@ -11,11 +11,11 @@ interface RatingScaleLegendProps {
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
return (
<div className="mt-3 flex w-full items-start justify-between px-1">
<div className="flex items-center gap-x-1">
<div className="flex items-center space-x-1">
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
<span className="text-xs text-slate-500">1</span>
</div>
<div className="flex items-center gap-x-1">
<div className="flex items-center space-x-1">
<span className="text-xs text-slate-500">{range}</span>
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
</div>
@@ -25,9 +25,9 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="size-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="size-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="size-4" />;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
@@ -36,8 +36,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
survey={survey}
setFilter={setFilter}
additionalInfo={
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-2 rounded-lg bg-slate-100 p-2">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
@@ -42,7 +42,6 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
return (
<button
type="button"
onClick={scrollToTop}
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
showButton ? "opacity-80" : "opacity-0"
@@ -18,7 +18,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
const { t } = useTranslation();
const getIcon = (elementType: TSurveyElementTypeEnum) => {
const Icon = getElementIcon(elementType, t);
return <Icon className="mt-[3px] size-5 shrink-0 text-slate-600" />;
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
};
return (
@@ -30,7 +30,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<TimerIcon className="size-5" />
<TimerIcon className="h-5 w-5" />
</TooltipTrigger>
<TooltipContent side={"top"}>
<p className="text-center font-normal">{t("workspace.surveys.summary.ttc_tooltip")}</p>
@@ -42,7 +42,7 @@ export const SummaryImpressions = ({
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="size-5" />
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
@@ -116,7 +116,7 @@ export const SummaryImpressions = ({
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="size-4 shrink-0" />
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("workspace.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
@@ -38,8 +38,8 @@ export const InteractiveCard = ({
{isLoading ? <div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div> : value}
</span>
{!isLoading && (
<div className="flex size-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{isActive ? <ChevronUpIcon className="size-4" /> : <ChevronDownIcon className="h-4 w-4" />}
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{isActive ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
</div>
)}
</div>
@@ -246,7 +246,7 @@ export const AnonymousLinksTab = ({
return (
<>
<div className="flex h-full flex-col justify-between gap-y-4">
<div className="flex h-full flex-col justify-between space-y-4">
<div className="flex w-full grow flex-col gap-6">
<AdvancedOptionToggle
htmlId="multi-use-link-switch"
@@ -354,7 +354,7 @@ export const AnonymousLinksTab = ({
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
<div className="flex items-center gap-2">
<CirclePlayIcon className="size-3.5 shrink-0 text-slate-50" />
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
</div>
<span className="text-sm text-slate-50">
@@ -149,8 +149,8 @@ export const AppTab = () => {
};
return (
<div className="flex flex-col justify-between gap-y-6 pb-4">
<div className="flex flex-col gap-y-6">
<div className="flex flex-col justify-between space-y-6 pb-4">
<div className="flex flex-col space-y-6">
<Alert variant={workspace.appSetupCompleted ? "success" : "warning"} size="default">
<AlertTitle>
{workspace.appSetupCompleted
@@ -171,14 +171,14 @@ export const AppTab = () => {
)}
</Alert>
<div className="flex flex-col gap-y-3">
<div className="flex flex-col space-y-3">
<H4>{t("workspace.surveys.summary.in_app.display_criteria")}</H4>
<div
className={
"flex w-full flex-col space-y-4 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-sm"
}>
<DisplayCriteriaItem
icon={<TimerResetIcon className="size-4" />}
icon={<TimerResetIcon className="h-4 w-4" />}
title={waitTime()}
titleSuffix={
survey.recontactDays !== null
@@ -188,7 +188,7 @@ export const AppTab = () => {
description={t("workspace.surveys.summary.in_app.display_criteria.time_based_description")}
/>
<DisplayCriteriaItem
icon={<UsersIcon className="size-4" />}
icon={<UsersIcon className="h-4 w-4" />}
title={getSegmentTitle(survey.segment)}
description={t("workspace.surveys.summary.in_app.display_criteria.audience_description")}
/>
@@ -197,9 +197,9 @@ export const AppTab = () => {
key={trigger.actionClass.id}
icon={
trigger.actionClass.type === "code" ? (
<CodeXmlIcon className="size-4" />
<CodeXmlIcon className="h-4 w-4" />
) : (
<MousePointerClickIcon className="size-4" />
<MousePointerClickIcon className="h-4 w-4" />
)
}
title={trigger.actionClass.name}
@@ -209,7 +209,7 @@ export const AppTab = () => {
))}
{survey.displayPercentage !== null && survey.displayPercentage > 0 && (
<DisplayCriteriaItem
icon={<PercentIcon className="size-4" />}
icon={<PercentIcon className="h-4 w-4" />}
title={t("workspace.surveys.summary.in_app.display_criteria.randomizer", {
percentage: survey.displayPercentage,
})}
@@ -219,7 +219,7 @@ export const AppTab = () => {
/>
)}
<DisplayCriteriaItem
icon={<Repeat1Icon className="size-4" />}
icon={<Repeat1Icon className="h-4 w-4" />}
title={displayOption()}
description={t("workspace.surveys.summary.in_app.display_criteria.recontact_description")}
/>
@@ -151,7 +151,7 @@ export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtml
</Button>
{/* Security Warning */}
<Alert variant="warning" className="flex items-start gap-2">
<AlertTriangleIcon className="mt-0.5 size-4 shrink-0" />
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
<AlertDescription>{t("workspace.surveys.share.custom_html.security_warning")}</AlertDescription>
</Alert>
</form>
@@ -20,7 +20,7 @@ export const DocumentationLinksSection = ({ title, links }: DocumentationLinksSe
const { t } = useTranslation();
return (
<div className="flex w-full flex-col gap-y-3">
<div className="flex w-full flex-col space-y-3">
<H4>{title}</H4>
{links.map((link) => (
<Alert key={link.title} size="small" variant="default">
@@ -15,7 +15,7 @@ export const DocumentationLinks = ({ links }: DocumentationLinksProps) => {
const { t } = useTranslation();
return (
<div className="flex w-full flex-col gap-y-2">
<div className="flex w-full flex-col space-y-2">
{links.map((link) => (
<div key={link.title} className="flex w-full flex-col gap-3">
<Alert variant="outbound" size="small">
@@ -15,7 +15,7 @@ export const DynamicPopupTab = ({ surveyId }: DynamicPopupTabProps) => {
const { workspace } = useWorkspace();
return (
<div className="flex h-full flex-col justify-between gap-y-4" data-testid="dynamic-popup-container">
<div className="flex h-full flex-col justify-between space-y-4" data-testid="dynamic-popup-container">
<Alert variant="info" size="default">
<AlertTitle>{t("workspace.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>{t("workspace.surveys.share.dynamic_popup.alert_description")}</AlertDescription>
@@ -131,9 +131,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="mb-6 flex gap-2">
<div className="size-3 rounded-full bg-red-500" />
<div className="size-3 rounded-full bg-amber-500" />
<div className="size-3 rounded-full bg-emerald-500" />
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
<div className="h-3 w-3 rounded-full bg-emerald-500" />
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
@@ -205,7 +205,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
};
return (
<div className="flex h-full w-full flex-col gap-y-4">
<div className="flex h-full w-full flex-col space-y-4">
<TabBar
tabs={tabs}
activeId={activeTab}
@@ -184,7 +184,7 @@ export const PersonalLinksTab = ({
}
return (
<div className="flex h-full flex-col justify-between gap-y-4">
<div className="flex h-full flex-col justify-between space-y-4">
<FormProvider {...form}>
<div className="flex grow flex-col gap-6">
{/* Recipients Section */}
@@ -247,7 +247,7 @@ export const PersonalLinksTab = ({
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 size-4" />
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
@@ -169,7 +169,7 @@ export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabP
{survey.slug && !isEditing && (
<>
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
<Copy className="mr-2 size-4" />
<Copy className="mr-2 h-4 w-4" />
{t("common.copy")} URL
</Button>
<Button
@@ -177,7 +177,7 @@ export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabP
variant="destructive"
onClick={() => setShowRemoveDialog(true)}
disabled={isReadOnly}>
<Trash2 className="mr-2 size-4" />
<Trash2 className="mr-2 h-4 w-4" />
{t("common.remove")}
</Button>
</>
@@ -77,7 +77,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
<>
{isLoading && (
<div className="flex flex-col items-center gap-2">
<LoaderCircle className="size-8 animate-spin text-slate-500" />
<LoaderCircle className="h-8 w-8 animate-spin text-slate-500" />
<p className="text-sm text-slate-500">{t("workspace.surveys.summary.generating_qr_code")}</p>
</div>
)}
@@ -91,7 +91,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
{!isLoading && !hasError && (
<div className="flex flex-col items-start justify-center gap-4">
<div className="flex size-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div className="flex h-[184px] w-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div ref={qrCodeRef} className="h-full w-full" />
</div>
<Button
@@ -103,9 +103,9 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
? t("workspace.surveys.summary.downloading_qr_code")
: t("workspace.surveys.summary.download_qr_code")}
{isDownloading ? (
<LoaderCircle className="size-4 animate-spin" />
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Download className="size-4" />
<Download className="h-4 w-4" />
)}
</Button>
</div>
@@ -117,7 +117,7 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
tooltip={tab.label}
isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="size-4 text-slate-700" />
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -145,7 +145,7 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
<tab.icon className="size-4 text-slate-700" />
<tab.icon className="h-4 w-4 text-slate-700" />
</Button>
</TooltipRenderer>
))}
@@ -62,27 +62,27 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
type="button"
onClick={() => handleViewChange("share")}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<Share2Icon className="size-8 stroke-1 text-slate-900" />
<Share2Icon className="h-8 w-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.share_survey")}
</button>
<button
type="button"
onClick={() => handleEmbedViewWithTab(tabs[1].id)}
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="size-8 stroke-1 text-slate-900" />
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button>
<Link
href={`/workspaces/${workspace?.id}/settings/account/notifications`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BellRing className="size-8 stroke-1 text-slate-900" />
<BellRing className="h-8 w-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/workspaces/${workspace?.id}/settings/workspace/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BlocksIcon className="size-8 stroke-1 text-slate-900" />
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("workspace.surveys.summary.setup_integrations")}
</Link>
</div>
@@ -8,7 +8,7 @@ interface TabContainerProps {
export const TabContainer = ({ title, description, children }: TabContainerProps) => {
return (
<div className="flex h-full grow flex-col items-start gap-y-4">
<div className="flex h-full grow flex-col items-start space-y-4">
<div className="pb-2">
<H3>{title}</H3>
<Small color="muted" margin="headerDescription">
@@ -389,7 +389,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
<span className="flex items-center gap-2">
{t("common.download")}
{isDownloading && <Loader2 className="size-3 animate-spin" strokeWidth={1.5} />}
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
</span>
</PopoverTriggerButton>
</DropdownMenuTrigger>
@@ -158,7 +158,7 @@ export const ElementFilterComboBox = ({
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
{filterOptions.length > 1 && <ChevronIcon className="size-4 flex-shrink-0 opacity-50" />}
{filterOptions.length > 1 && <ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions.map((o, index) => {
@@ -203,7 +203,7 @@ export const ElementFilterComboBox = ({
onClick={(e) => handleRemoveTag(e, value)}
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
{value}
<X className="size-3" />
<X className="h-3 w-3" />
</button>
);
@@ -26,8 +26,8 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
} from "lucide-react";
@@ -140,7 +140,7 @@ const getIcon = (type: string) => {
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
type
];
return IconComponent ? <IconComponent className="size-5" strokeWidth={1.5} /> : null;
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
};
const getIconBackground = (type: OptionsType | string): string => {
@@ -231,7 +231,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
className="flex-shrink-0"
aria-expanded={open}
aria-label={t("common.select")}>
<ChevronIcon className="size-4 opacity-50" />
<ChevronIcon className="h-4 w-4 opacity-50" />
</Button>
</div>
@@ -57,9 +57,9 @@ export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverT
<span className="text-sm text-slate-700">{children}</span>
<div className="ml-3">
{isOpen ? (
<ChevronUp className="ml-2 size-4 opacity-50" />
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 size-4 opacity-50" />
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</button>
@@ -252,7 +252,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<p className="font-semibold text-slate-800">
{t("workspace.surveys.summary.show_all_responses_that_match")}
</p>
<div className="flex items-center gap-x-2">
<div className="flex items-center space-x-2">
<Select
value={filterValue.responseStatus ?? "all"}
onValueChange={(val) => {
@@ -1,11 +1,10 @@
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
+2 -11
View File
@@ -313,18 +313,9 @@ describe("handleErrorResponse", () => {
expect(body.message).toBe("bad input");
});
test("returns 404 notFound for ResourceNotFoundError", async () => {
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toEqual({
code: "not_found",
message: "Survey not found",
details: {
resource_id: "id-1",
resource_type: "Survey",
},
});
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
+5 -4
View File
@@ -29,10 +29,11 @@ export const handleErrorResponse = (error: any): Response => {
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse(error.resourceType, error.resourceId);
}
if (error instanceof DatabaseError || error instanceof InvalidInputError) {
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
error instanceof ResourceNotFoundError
) {
return responses.badRequestResponse(error.message);
}
return responses.internalServerErrorResponse("Some error occurred");
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
resolveClientApiIds: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
});
test("returns a bad request response when the response id is missing", async () => {
@@ -245,34 +245,6 @@ describe("putResponseHandler", () => {
});
});
test("returns not found when the workspace id cannot be resolved", async () => {
mocks.resolveClientApiIds.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Workspace not found",
details: {
resource_id: "unknown_workspace_or_env",
resource_type: "Workspace",
},
});
expect(mocks.getResponse).not.toHaveBeenCalled();
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
expect(result.response.status).toBe(200);
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
});
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
@@ -1,12 +1,7 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganization } from "@/lib/organization/service";
@@ -160,16 +155,6 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(InvalidInputError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -16,7 +11,6 @@ import {
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -110,16 +104,6 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contact?.id ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
@@ -147,13 +131,6 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -195,19 +190,7 @@ describe("createResponse V2", () => {
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId or displayId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["someOtherField"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw InvalidInputError on P2002 with displayId target (race condition)", async () => {
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
@@ -216,7 +199,7 @@ describe("createResponse V2", () => {
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(InvalidInputError);
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
@@ -2,12 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -17,7 +12,6 @@ import {
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
import { assertDisplayOwnership } from "@/lib/display/service";
import { getOrganization } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -105,16 +99,6 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
if (responseInput.displayId) {
await assertDisplayOwnership(
responseInput.displayId,
workspaceId,
responseInput.surveyId,
contactId ?? null,
tx
);
}
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaClient = tx ?? prisma;
@@ -138,13 +122,6 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (
error.code === "P2002" &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes("displayId")
) {
throw new InvalidInputError(`Display ${responseInput.displayId} is already linked to a response`);
}
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
@@ -415,44 +415,6 @@ describe("withV3ApiWrapper", () => {
]);
});
test("returns 413 problem response for oversized JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{}",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
"x-request-id": "req-payload-too-large",
},
}),
{} as never
);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
code: "payload_too_large",
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
requestId: "req-payload-too-large",
status: 413,
title: "Payload Too Large",
})
);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
@@ -40,40 +40,6 @@ describe("parseAndValidateJsonBody", () => {
});
});
test("returns a payload too large response when the request body exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
body: "{}",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("payload_too_large");
expect(result.response.status).toBe(413);
await expect(result.response.json()).resolves.toEqual({
code: "payload_too_large",
message: "Payload Too Large",
details: {
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
-76
View File
@@ -1,76 +0,0 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
RequestBodyTooLargeError,
parseJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./request-body";
const createStreamingRequest = (chunks: string[]): Request =>
new Request("http://localhost/api/test", {
method: "POST",
body: new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
describe("request body parsing", () => {
test("rejects a request when content-length exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
},
body: "{}",
});
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
name: "RequestBodyTooLargeError",
});
});
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
});
test("allows a body exactly at the body limit", async () => {
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
const request = new Request("http://localhost/api/test", {
method: "POST",
body: rawBody,
});
const body = await readRequestBodyWithLimit(request);
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
expect(body).toBe(rawBody);
});
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
body: "{invalid-json",
});
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
});
test("returns an empty string for requests without a body", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
});
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
});
});
+1 -2
View File
@@ -1,6 +1,5 @@
import "server-only";
import { Prisma } from "@prisma/client";
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -213,7 +212,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
+1 -53
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
export const selectDisplay = {
@@ -146,58 +146,6 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
}
);
export const getDisplayForResponseValidation = async (
displayId: string,
tx?: Prisma.TransactionClient
): Promise<{
surveyId: string;
workspaceId: string;
responseId: string | null;
contactId: string | null;
} | null> => {
validateInputs([displayId, ZId]);
const client = tx ?? prisma;
try {
const display = await client.display.findUnique({
where: { id: displayId },
select: {
surveyId: true,
contactId: true,
response: { select: { id: true } },
survey: { select: { workspaceId: true } },
},
});
if (!display) return null;
return {
surveyId: display.surveyId,
workspaceId: display.survey.workspaceId,
responseId: display.response?.id ?? null,
contactId: display.contactId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) throw new DatabaseError(error.message);
throw error;
}
};
export const assertDisplayOwnership = async (
displayId: string,
workspaceId: string,
surveyId: string,
contactId: string | null,
tx?: Prisma.TransactionClient
): Promise<void> => {
const display = await getDisplayForResponseValidation(displayId, tx);
if (!display) throw new InvalidInputError(`Display ${displayId} not found`);
if (display.workspaceId !== workspaceId)
throw new InvalidInputError(`Display ${displayId} belongs to a different workspace`);
if (display.surveyId !== surveyId)
throw new InvalidInputError(`Display ${displayId} is associated with a different survey`);
if (display.responseId) throw new InvalidInputError(`Display ${displayId} is already linked to a response`);
if (display.contactId !== null && display.contactId !== contactId)
throw new InvalidInputError(`Display ${displayId} belongs to a different contact`);
};
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
@@ -3,18 +3,14 @@ import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
assertDisplayOwnership,
getDisplayCountBySurveyId,
getDisplayForResponseValidation,
getDisplaysByContactId,
getDisplaysBySurveyIdWithContact,
} from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockWorkspaceId = "clqkr8dlv000308jybb08evgz";
const mockResponseId = "clqnfg59i000208i426pb4wcv";
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
const mockDisplaysForContact = [
@@ -294,96 +290,3 @@ describe("getDisplaysBySurveyIdWithContact", () => {
});
});
});
const mockDisplayRecord = {
surveyId: mockSurveyId,
contactId: null as string | null,
response: null as { id: string } | null,
survey: { workspaceId: mockWorkspaceId },
};
describe("getDisplayForResponseValidation", () => {
test("returns null when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toBeNull();
});
test("returns mapped shape when display is found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
response: { id: mockResponseId },
} as any);
const result = await getDisplayForResponseValidation(mockDisplayId);
expect(result).toEqual({
surveyId: mockSurveyId,
workspaceId: mockWorkspaceId,
responseId: mockResponseId,
contactId: mockContactId,
});
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
vi.mocked(prisma.display.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
await expect(getDisplayForResponseValidation(mockDisplayId)).rejects.toThrow(DatabaseError);
});
});
describe("assertDisplayOwnership", () => {
test("throws InvalidInputError when display is not found", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(null);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when workspaceId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, "wrong-workspace", mockSurveyId, null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when surveyId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue(mockDisplayRecord as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, "wrong-survey", null)
).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError when display is already linked to a response", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
response: { id: mockResponseId },
} as any);
await expect(assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, null)).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError when contactId does not match", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: "contact-a",
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, "contact-b")
).rejects.toThrow(InvalidInputError);
});
test("resolves without error when all ownership checks pass", async () => {
vi.mocked(prisma.display.findUnique).mockResolvedValue({
...mockDisplayRecord,
contactId: mockContactId,
} as any);
await expect(
assertDisplayOwnership(mockDisplayId, mockWorkspaceId, mockSurveyId, mockContactId)
).resolves.toBeUndefined();
});
});
-31
View File
@@ -1,7 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
@@ -325,35 +324,5 @@ describe("updateResponse", () => {
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
test("should throw ResourceNotFoundError when response is deleted during update", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when Prisma reports a missing response record", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
});
});
-8
View File
@@ -3,7 +3,6 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -570,13 +569,6 @@ export const updateResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
throw new ResourceNotFoundError("Response", responseId);
}
throw new DatabaseError(error.message);
}
@@ -38,50 +38,6 @@ describe("convertToCsv", () => {
parseSpy.mockRestore();
});
test("should defang formula injection payloads in cell values", async () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p, age: 0 }));
const csv = await convertToCsv(["name", "age"], rows);
const lines = csv.trim().split("\n").slice(1); // drop header
payloads.forEach((p, i) => {
// each value should be prefixed with a single quote so the spreadsheet
// app treats it as text rather than a formula
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
});
});
test("should defang formula injection in field/header names", async () => {
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=evil","age"');
expect(lines[1]).toBe('"x",1');
});
test("should not alter benign strings", async () => {
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
const lines = csv.trim().split("\n");
expect(lines[1]).toBe('"Alice = Bob"');
});
test("should preserve distinct columns whose labels collide after sanitization", async () => {
// "=field" and "'=field" both render as "'=field" once defanged, but the
// underlying row keys must stay distinct so neither cell is dropped.
const csv = await convertToCsv(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const lines = csv.trim().split("\n");
expect(lines[0]).toBe('"\'=field","\'=field"');
expect(lines[1]).toBe('"a","b"');
});
});
describe("convertToXlsxBuffer", () => {
@@ -104,54 +60,4 @@ describe("convertToXlsxBuffer", () => {
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
test("should defang formula injection payloads in xlsx cells", () => {
const payloads = [
'=HYPERLINK("https://evil.tld","Click")',
"+1+1",
"-2+3",
"@SUM(A1:A2)",
"\tleading-tab",
"\rleading-cr",
];
const rows = payloads.map((p) => ({ name: p }));
const buffer = convertToXlsxBuffer(["name"], rows);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
payloads.forEach((p, i) => {
const cell = sheet[`A${i + 2}`]; // row 1 is header
// value stored as plain text, not as a formula (no `f` property)
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${p}`);
});
});
test("should defang formula injection in xlsx header names", () => {
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
const headerCell = sheet["A1"];
expect(headerCell.f).toBeUndefined();
expect(headerCell.v).toBe("'=evil");
// benign header untouched
expect(sheet["B1"].v).toBe("name");
// data row mapped via original key
expect(sheet["A2"].v).toBe("x");
expect(sheet["B2"].v).toBe("Alice");
});
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
// Original keys "=field" and "'=field" both render as "'=field"; ensure
// both cells survive instead of one overwriting the other.
const buffer = convertToXlsxBuffer(
["=field", "'=field"],
[{ "=field": "a", "'=field": "b" }]
);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
expect(sheet["A1"].v).toBe("'=field");
expect(sheet["B1"].v).toBe("'=field");
expect(sheet["A2"].v).toBe("a");
expect(sheet["B2"].v).toBe("b");
});
});
@@ -33,7 +33,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
variant="secondary"
title={t("common.select_language")}
aria-label={t("common.select_language")}>
<Languages className="size-5" />
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -63,7 +63,7 @@ export const ShareSurveyLink = ({
key={surveyUrl}
enforceSurveyUrlWidth={enforceSurveyUrlWidth}
/>
<div className="flex items-center justify-center gap-x-2">
<div className="flex items-center justify-center space-x-2">
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
<Button
disabled={!surveyUrl}
@@ -35,13 +35,13 @@ export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps)
{hiddenFieldsData.map((fieldData) => {
return (
<div key={fieldData.field}>
<div className="flex gap-x-2 text-sm text-slate-500">
<div className="flex space-x-2 text-sm text-slate-500">
<p>{fieldData.field}</p>
<div className="flex items-center gap-x-2 rounded-full bg-slate-100 px-2">
<div className="flex items-center space-x-2 rounded-full bg-slate-100 px-2">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<EyeOffIcon className="size-4" />
<EyeOffIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]" side="top">
{t("common.hidden_field")}
@@ -122,7 +122,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
<ResponseBadges
items={[{ value: responseData.toString() }]}
isExpanded={isExpanded}
icon={<PhoneIcon className="size-4 text-slate-500" />}
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
@@ -134,7 +134,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
<ResponseBadges
items={[{ value: responseData.toString() }]}
isExpanded={isExpanded}
icon={<CheckCheckIcon className="size-4 text-slate-500" />}
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);
@@ -146,7 +146,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
<ResponseBadges
items={[{ value: responseData.toString() }]}
isExpanded={isExpanded}
icon={<MousePointerClickIcon className="size-4 text-slate-500" />}
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
/>
);

Some files were not shown because too many files have changed in this diff Show More