Merge branch 'main' of https://github.com/formbricks/formbricks into Naidu-4444/main

This commit is contained in:
Piyush Gupta
2025-06-24 17:53:27 +05:30
12 changed files with 292 additions and 177 deletions
-1
View File
@@ -309,7 +309,6 @@
"project_not_found": "Projekt nicht gefunden",
"project_permission_not_found": "Projekt-Berechtigung nicht gefunden",
"projects": "Projekte",
"projects_limit_reached": "Projektlimit erreicht",
"question": "Frage",
"question_id": "Frage-ID",
"questions": "Fragen",
-1
View File
@@ -309,7 +309,6 @@
"project_not_found": "Project not found",
"project_permission_not_found": "Project permission not found",
"projects": "Projects",
"projects_limit_reached": "Projects limit reached",
"question": "Question",
"question_id": "Question ID",
"questions": "Questions",
-1
View File
@@ -309,7 +309,6 @@
"project_not_found": "Projet non trouvé",
"project_permission_not_found": "Autorisation de projet non trouvée",
"projects": "Projets",
"projects_limit_reached": "Limite de projets atteinte",
"question": "Question",
"question_id": "ID de la question",
"questions": "Questions",
-1
View File
@@ -309,7 +309,6 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"projects_limit_reached": "Limites de projetos atingidos",
"question": "Pergunta",
"question_id": "ID da Pergunta",
"questions": "Perguntas",
-1
View File
@@ -309,7 +309,6 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"projects_limit_reached": "Limite de projetos atingido",
"question": "Pergunta",
"question_id": "ID da pergunta",
"questions": "Perguntas",
-1
View File
@@ -309,7 +309,6 @@
"project_not_found": "找不到專案",
"project_permission_not_found": "找不到專案權限",
"projects": "專案",
"projects_limit_reached": "已達到專案上限",
"question": "問題",
"question_id": "問題 ID",
"questions": "問題",
@@ -6,13 +6,70 @@ import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
const bulkContactEndpoint: ZodOpenApiOperationObject = {
operationId: "uploadBulkContacts",
summary: "Upload Bulk Contacts",
description: "Uploads contacts in bulk",
description:
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
requestBody: {
required: true,
description: "The contacts to upload",
description:
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
content: {
"application/json": {
schema: ZContactBulkUploadRequest,
example: {
environmentId: "env_01h2xce9q8p3w4x5y6z7a8b9c0",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email Address",
},
value: "john.doe@example.com",
},
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "John",
},
{
attributeKey: {
key: "lastName",
name: "Last Name",
},
value: "Doe",
},
],
},
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email Address",
},
value: "jane.smith@example.com",
},
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "Jane",
},
{
attributeKey: {
key: "lastName",
name: "Last Name",
},
value: "Smith",
},
],
},
],
},
},
},
},
+44 -51
View File
@@ -4,11 +4,19 @@ import { logger } from "@formbricks/logger";
// Define the v1 (now v2) client endpoints to be merged
const v1ClientEndpoints = {
"/responses/{responseId}": {
"/client/{environmentId}/responses/{responseId}": {
put: {
security: [],
description:
"Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.",
parameters: [
{
in: "path",
name: "environmentId",
required: true,
schema: { type: "string" },
description: "The ID of the environment.",
},
{
in: "path",
name: "responseId",
@@ -57,14 +65,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Response"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/responses": {
"/client/{environmentId}/responses": {
post: {
security: [],
description:
"Create a response for a survey and its fields with the user's responses. The userId & meta here is optional",
requestBody: {
@@ -89,14 +98,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Response"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/contacts/{userId}/attributes": {
"/client/{environmentId}/contacts/{userId}/attributes": {
put: {
security: [],
description:
"Update a contact's attributes in Formbricks to keep them in sync with your app or when you want to set a custom attribute in Formbricks.",
parameters: [
@@ -138,14 +148,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Contacts"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/identify/contacts/{userId}": {
"/client/{environmentId}/identify/contacts/{userId}": {
get: {
security: [],
description:
"Retrieves a contact's state including their segments, displays, responses and other tracking information. If the contact doesn't exist, it will be created.",
parameters: [
@@ -167,14 +178,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Contacts"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/displays": {
"/client/{environmentId}/displays": {
post: {
security: [],
description:
"Create a new display for a valid survey ID. If a userId is passed, the display is linked to the user.",
requestBody: {
@@ -199,48 +211,25 @@ const v1ClientEndpoints = {
tags: ["Client API > Display"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/displays/{displayId}": {
put: {
description:
"Update a Display for a user. A use case can be when a user submits a response & you want to link it to an existing display.",
parameters: [{ in: "path", name: "displayId", required: true, schema: { type: "string" } }],
requestBody: {
content: {
"application/json": {
schema: { example: { responseId: "response123" }, type: "object" },
},
},
},
responses: {
"200": {
content: {
"application/json": {
example: { displayId: "display123" },
schema: { type: "object" },
},
},
description: "OK",
},
},
summary: "Update Display",
tags: ["Client API > Display"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/environment": {
"/client/{environmentId}/environment": {
get: {
security: [],
description: "Retrieves the environment state to be used in Formbricks SDKs",
parameters: [
{
in: "path",
name: "environmentId",
required: true,
schema: { type: "string" },
description: "The ID of the environment.",
},
],
responses: {
"200": {
content: {
@@ -256,14 +245,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Environment"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/user": {
"/client/{environmentId}/user": {
post: {
security: [],
description:
"Endpoint for creating or identifying a user within the specified environment. If the user already exists, this will identify them and potentially update user attributes. If they don't exist, it will create a new user.",
requestBody: {
@@ -288,14 +278,15 @@ const v1ClientEndpoints = {
tags: ["Client API > User"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/storage": {
"/client/{environmentId}/storage": {
post: {
security: [],
summary: "Upload Private File",
description:
"API endpoint for uploading private files. Uploaded files are kept private so that only users with access to the specified environment can retrieve them. The endpoint validates the survey ID, file name, and file type from the request body, and returns a signed URL for S3 uploads along with a local upload URL.",
@@ -442,14 +433,15 @@ const v1ClientEndpoints = {
},
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks API Server",
},
],
},
},
"/{environmentId}/storage/local": {
"/client/{environmentId}/storage/local": {
post: {
security: [],
summary: "Upload Private File to Local Storage",
description:
'API endpoint for uploading private files to local storage. The request must include a valid signature, UUID, and timestamp to verify the upload. The file is provided as a Base64 encoded string in the request body. The "Content-Type" header must be set to a valid MIME type, and the file data must be a valid file object (buffer).',
@@ -478,7 +470,8 @@ const v1ClientEndpoints = {
},
fileName: {
type: "string",
description: "The URI encoded file name.",
description:
"This must be the `fileName` returned from the [Upload Private File](/api-v2-reference/client-api->-file-upload/upload-private-file) endpoint (Step 1).",
},
fileType: {
type: "string",
+91 -50
View File
@@ -34,11 +34,18 @@ tags:
security:
- apiKeyAuth: []
paths:
/responses/{responseId}:
/client/{environmentId}/responses/{responseId}:
put:
security: []
description: Update an existing response for example when you want to mark a
response as finished or you want to change an existing response's value.
parameters:
- in: path
name: environmentId
required: true
schema:
type: string
description: The ID of the environment.
- in: path
name: responseId
required: true
@@ -77,10 +84,11 @@ paths:
tags:
- Client API > Response
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/responses:
/client/{environmentId}/responses:
post:
security: []
description: Create a response for a survey and its fields with the user's
responses. The userId & meta here is optional
requestBody:
@@ -104,10 +112,11 @@ paths:
tags:
- Client API > Response
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/contacts/{userId}/attributes:
/client/{environmentId}/contacts/{userId}/attributes:
put:
security: []
description: Update a contact's attributes in Formbricks to keep them in sync
with your app or when you want to set a custom attribute in Formbricks.
parameters:
@@ -152,10 +161,11 @@ paths:
tags:
- Client API > Contacts
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/identify/contacts/{userId}:
/client/{environmentId}/identify/contacts/{userId}:
get:
security: []
description: Retrieves a contact's state including their segments, displays,
responses and other tracking information. If the contact doesn't exist,
it will be created.
@@ -184,10 +194,11 @@ paths:
tags:
- Client API > Contacts
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/displays:
/client/{environmentId}/displays:
post:
security: []
description: Create a new display for a valid survey ID. If a userId is passed,
the display is linked to the user.
requestBody:
@@ -211,43 +222,19 @@ paths:
tags:
- Client API > Display
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/displays/{displayId}:
put:
description: Update a Display for a user. A use case can be when a user submits
a response & you want to link it to an existing display.
/client/{environmentId}/environment:
get:
security: []
description: Retrieves the environment state to be used in Formbricks SDKs
parameters:
- in: path
name: displayId
name: environmentId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
example:
responseId: response123
type: object
responses:
"200":
content:
application/json:
example:
displayId: display123
schema:
type: object
description: OK
summary: Update Display
tags:
- Client API > Display
servers:
- url: https://app.formbricks.com/api/v2/client
description: Formbricks Client
/{environmentId}/environment:
get:
description: Retrieves the environment state to be used in Formbricks SDKs
description: The ID of the environment.
responses:
"200":
content:
@@ -262,10 +249,11 @@ paths:
tags:
- Client API > Environment
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/user:
/client/{environmentId}/user:
post:
security: []
description: Endpoint for creating or identifying a user within the specified
environment. If the user already exists, this will identify them and
potentially update user attributes. If they don't exist, it will create
@@ -292,10 +280,11 @@ paths:
tags:
- Client API > User
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/storage:
/client/{environmentId}/storage:
post:
security: []
summary: Upload Private File
description: API endpoint for uploading private files. Uploaded files are kept
private so that only users with access to the specified environment can
@@ -402,10 +391,11 @@ paths:
example:
error: Survey survey123 not found
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks API Server
/{environmentId}/storage/local:
/client/{environmentId}/storage/local:
post:
security: []
summary: Upload Private File to Local Storage
description: API endpoint for uploading private files to local storage. The
request must include a valid signature, UUID, and timestamp to verify
@@ -433,7 +423,9 @@ paths:
description: The ID of the survey associated with the file.
fileName:
type: string
description: The URI encoded file name.
description: This must be the `fileName` returned from the [Upload Private
File](/api-v2-reference/client-api->-file-upload/upload-private-file)
endpoint (Step 1).
fileType:
type: string
description: The MIME type of the file.
@@ -1531,12 +1523,15 @@ paths:
put:
operationId: uploadBulkContacts
summary: Upload Bulk Contacts
description: Uploads contacts in bulk
description: Uploads contacts in bulk. Each contact in the payload must have an
'email' attribute present in their attributes array. The email attribute
is mandatory and must be a valid email format.
tags:
- Management API > Contacts
requestBody:
required: true
description: The contacts to upload
description: The contacts to upload. Each contact **must include an 'email'
attribute** in their attributes array.
content:
application/json:
schema:
@@ -1571,10 +1566,39 @@ paths:
- value
required:
- attributes
maxItems: 1000
maxItems: 250
required:
- environmentId
- contacts
example:
environmentId: env_01h2xce9q8p3w4x5y6z7a8b9c0
contacts:
- attributes:
- attributeKey:
key: email
name: Email Address
value: john.doe@example.com
- attributeKey:
key: firstName
name: First Name
value: John
- attributeKey:
key: lastName
name: Last Name
value: Doe
- attributes:
- attributeKey:
key: email
name: Email Address
value: jane.smith@example.com
- attributeKey:
key: firstName
name: First Name
value: Jane
- attributeKey:
key: lastName
name: Last Name
value: Smith
responses:
"200":
description: Contacts uploaded successfully.
@@ -4383,6 +4407,22 @@ components:
- enabled
- message
description: Email verification configuration (deprecated)
recaptcha:
type:
- object
- "null"
properties:
enabled:
type: boolean
threshold:
type: number
multipleOf: 0.1
minimum: 0.1
maximum: 0.9
required:
- enabled
- threshold
description: Google reCAPTCHA configuration
displayPercentage:
type:
- number
@@ -4431,6 +4471,7 @@ components:
- inlineTriggers
- isBackButtonHidden
- verifyEmail
- recaptcha
- displayPercentage
- questions
webhook:
@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
import { useState } from "preact/hooks";
@@ -26,7 +27,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
const [isLoading, setIsLoading] = useState(true);
return (
<div className="fb-group/image fb-relative fb-mb-4 fb-block fb-min-h-40 fb-rounded-md">
<div className="fb-group/image fb-relative fb-mb-6 fb-block fb-min-h-40 fb-rounded-md">
{isLoading ? (
<div className="fb-absolute fb-inset-auto fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
) : null}
@@ -35,10 +36,16 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
key={imgUrl}
src={imgUrl}
alt={altText}
className="fb-rounded-custom"
className={cn(
"fb-rounded-custom fb-max-h-[40dvh] fb-mx-auto fb-object-contain",
isLoading ? "fb-opacity-0" : ""
)}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
/>
) : null}
{videoUrlWithParams ? (
@@ -48,10 +55,13 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
src={videoUrlWithParams}
title="Question Video"
frameBorder="0"
className="fb-rounded-custom fb-aspect-video fb-w-full"
className={cn("fb-rounded-custom fb-aspect-video fb-w-full", isLoading ? "fb-opacity-0" : "")}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
/>
@@ -171,7 +171,7 @@ describe("PictureSelectionQuestion", () => {
render(<PictureSelectionQuestion {...mockProps} />);
const images = screen.getAllByRole("img");
const label = images[0].closest("label");
const label = images[0].closest("button");
fireEvent.keyDown(label!, { key: " " });
@@ -43,6 +43,13 @@ export function PictureSelectionQuestion({
isBackButtonHidden,
}: Readonly<PictureSelectionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
const initialLoadingState: Record<string, boolean> = {};
question.choices.forEach((choice) => {
initialLoadingState[choice.id] = true;
});
return initialLoadingState;
});
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -115,35 +122,75 @@ export function PictureSelectionQuestion({
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-2 fb-gap-4">
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
{questionChoices.map((choice) => (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
htmlFor={choice.id}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus:fb-outline-none fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] focus:fb-border-brand focus:fb-border-4 group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className="fb-h-full fb-w-full fb-object-cover"
/>
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
e.currentTarget.click();
e.currentTarget.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
{loadingImages[choice.id] && (
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className={cn(
"fb-h-full fb-w-full fb-object-cover",
loadingImages[choice.id] ? "fb-opacity-0" : ""
)}
onLoad={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
onError={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
/>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${question.id}`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</button>
<a
tabIndex={-1}
href={choice.imageUrl}
@@ -153,52 +200,25 @@ export function PictureSelectionQuestion({
onClick={(e) => {
e.stopPropagation();
}}
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-expand">
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
className="lucide lucide-image-down-icon lucide-image-down">
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
<path d="m14 19 3 3v-5.5" />
<path d="m17 22 3-3" />
<circle cx="9" cy="9" r="2" />
</svg>
</a>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${choice.id}-radio`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</label>
</div>
))}
</div>
</fieldset>