Compare commits

...

6 Commits

Author SHA1 Message Date
Dhruwang Jariwala
fcbb99c43d fix: (backport) jerky animation behaviour (#7158) (#7163) 2026-01-26 10:24:19 +05:30
Dhruwang Jariwala
ec415a7aa1 fix: (backport) nps & rating rtl UI (#7154) (#7162) 2026-01-23 18:29:09 +05:30
Anshuman Pandey
a1e53c9051 fix: [Backport] fixes response card UI for cta question (#7161) 2026-01-23 17:58:53 +05:30
Anshuman Pandey
680295c63e fix: [Backport] fixes the cta element survey not found error (#7160) 2026-01-23 17:58:31 +05:30
Anshuman Pandey
73b40469f7 fix: (BACKPORT) language variants not working for app surveys (#7151) (#7159) 2026-01-23 17:32:00 +05:30
Dhruwang Jariwala
282e061606 fix: language variants not working for app surveys (#7151) 2026-01-23 17:10:33 +05:30
11 changed files with 108 additions and 47 deletions

View File

@@ -3,6 +3,7 @@
import { CheckCircle2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -67,6 +68,16 @@ export const SingleResponseCardBody = ({
<VerifiedEmail responseData={response.data} />
)}
{elements.map((question) => {
// Skip CTA elements without external buttons only if they have no response data
// This preserves historical data from when buttonExternal was true
if (
question.type === TSurveyElementTypeEnum.CTA &&
!question.buttonExternal &&
!response.data[question.id]
) {
return null;
}
const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id)
);

View File

@@ -425,11 +425,19 @@ export const SurveyMenuBar = ({
const segment = await handleSegmentUpdate();
clearSurveyLocalStorage();
await updateSurveyAction({
const publishResult = await updateSurveyAction({
...localSurvey,
status,
segment,
});
if (!publishResult?.data) {
const errorMessage = getFormattedErrorMessage(publishResult);
toast.error(errorMessage);
setIsSurveyPublishing(false);
return;
}
setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
@@ -467,7 +475,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
{!isStorageConfigured && (
<div>

View File

@@ -259,6 +259,7 @@ export const PreviewSurvey = ({
setBlockId = f;
}}
onFinished={onFinished}
placement={placement}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</Modal>
@@ -363,6 +364,7 @@ export const PreviewSurvey = ({
}}
onFinished={onFinished}
isSpamProtectionEnabled={isSpamProtectionEnabled}
placement={placement}
/>
</Modal>
) : (

View File

@@ -170,7 +170,7 @@ export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: stri
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === language.toLowerCase() ||
surveyLanguage.language.code.toLowerCase() === language.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
);
});

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { Label } from "@/components/general/label";
import { cn } from "@/lib/utils";
import { cn, getRTLScaleOptionClasses } from "@/lib/utils";
interface NPSProps {
/** Unique identifier for the element container */
@@ -97,18 +97,9 @@ function NPS({
const isLast = number === 10; // Last option is 10
const isFirst = number === 0; // First option is 0
// Determine border radius and border classes
// Use right border for all items to create separators, left border only on first item
let borderRadiusClasses = "";
let borderClasses = "border-t border-b border-r";
if (isFirst) {
borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
borderClasses = "border-t border-b border-l border-r";
} else if (isLast) {
borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
// Last item keeps right border for rounded corner
}
// Use CSS logical properties for RTL-aware borders and border radius
// The fieldset's dir attribute automatically handles direction
const { borderRadiusClasses, borderClasses } = getRTLScaleOptionClasses(isFirst, isLast);
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
@@ -183,7 +174,7 @@ function NPS({
{/* NPS Options */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full px-[2px]">
<fieldset className="w-full px-[2px]" dir={dir}>
<legend className="sr-only">NPS rating options</legend>
<div className="flex w-full">{npsOptions.map((number) => renderNPSOption(number))}</div>

View File

@@ -15,7 +15,7 @@ import {
TiredFace,
WearyFace,
} from "@/components/general/smileys";
import { cn } from "@/lib/utils";
import { cn, getRTLScaleOptionClasses } from "@/lib/utils";
/**
* Get smiley color class based on range and index
@@ -220,18 +220,9 @@ function Rating({
const isLast = totalLength === number;
const isFirst = number === 1;
// Determine border radius and border classes
// Use right border for all items to create separators, left border only on first item
let borderRadiusClasses = "";
let borderClasses = "border-t border-b border-r";
if (isFirst) {
borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
borderClasses = "border-t border-b border-l border-r";
} else if (isLast) {
borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
// Last item keeps right border for rounded corner
}
// Use CSS logical properties for RTL-aware borders and border radius
// The parent div's dir attribute automatically handles direction
const { borderRadiusClasses, borderClasses } = getRTLScaleOptionClasses(isFirst, isLast);
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
@@ -418,7 +409,7 @@ function Rating({
{/* Rating Options */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full">
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Rating options</legend>
<div className="flex w-full px-[2px]">
{ratingOptions.map((number, index) => {

View File

@@ -35,3 +35,29 @@ export const stripInlineStyles = (html: string): string => {
KEEP_CONTENT: true,
});
};
/**
* Generate RTL-aware border radius and border classes for rating/NPS scale options
* Uses CSS logical properties that automatically adapt to text direction
* @param isFirst - Whether this is the first item in the scale
* @param isLast - Whether this is the last item in the scale
* @returns Object containing borderRadiusClasses and borderClasses
*/
export const getRTLScaleOptionClasses = (
isFirst: boolean,
isLast: boolean
): { borderRadiusClasses: string; borderClasses: string } => {
const borderRadiusClasses = cn(
isFirst &&
"[border-start-start-radius:var(--fb-input-border-radius)] [border-end-start-radius:var(--fb-input-border-radius)]",
isLast &&
"[border-start-end-radius:var(--fb-input-border-radius)] [border-end-end-radius:var(--fb-input-border-radius)]"
);
const borderClasses = cn(
"border-t border-b border-e", // block borders (top/bottom) and inline-end border
isFirst && "border-s" // inline-start border for first item
);
return { borderRadiusClasses, borderClasses };
};

View File

@@ -76,6 +76,7 @@ export function Survey({
isSpamProtectionEnabled,
dir = "auto",
setDir,
placement,
}: SurveyContainerProps) {
let apiClient: ApiClient | null = null;
@@ -916,6 +917,7 @@ export function Survey({
setBlockId={setBlockId}
shouldResetBlockId={shouldResetQuestionId}
fullSizeCards={fullSizeCards}
placement={placement}
/>
);
}

View File

@@ -2,6 +2,7 @@ import { MutableRef } from "preact/hooks";
import { useEffect, useMemo, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import React from "react";
import { type TPlacement } from "@formbricks/types/common";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TCardArrangementOptions } from "@formbricks/types/styling";
@@ -17,6 +18,7 @@ interface StackedCardProps {
cardWidth: number;
hovered: boolean;
cardArrangement: TCardArrangementOptions;
placement: TPlacement;
}
export const StackedCard = ({
@@ -31,17 +33,24 @@ export const StackedCard = ({
cardWidth,
hovered,
cardArrangement,
placement,
}: StackedCardProps) => {
const isHidden = offset < 0;
const [delayedOffset, setDelayedOffset] = useState<number>(offset);
const [contentOpacity, setContentOpacity] = useState<number>(0);
const currentCardHeight = offset === 0 ? "auto" : offset < 0 ? "initial" : cardHeight;
const getBottomStyles = () => {
const getTopBottomStyles = () => {
if (survey.type !== "link")
return {
bottom: 0,
};
if (placement === "bottomLeft" || placement === "bottomRight") {
return {
bottom: 0,
};
} else if (placement === "topLeft" || placement === "topRight") {
return {
top: 0,
};
}
};
const getDummyCardContent = () => {
@@ -111,7 +120,7 @@ export const StackedCard = ({
pointerEvents: offset === 0 ? "auto" : "none",
...borderStyles,
...straightCardArrangementStyles,
...getBottomStyles(),
...getTopBottomStyles(),
}}
className="pointer rounded-custom bg-survey-bg absolute inset-x-0 overflow-hidden">
<div

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { JSX } from "react";
import { type TPlacement } from "@formbricks/types/common";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TCardArrangementOptions } from "@formbricks/types/styling";
@@ -19,6 +20,7 @@ interface StackedCardsContainerProps {
setBlockId: (blockId: string) => void;
shouldResetBlockId?: boolean;
fullSizeCards: boolean;
placement?: TPlacement;
}
export function StackedCardsContainer({
@@ -30,6 +32,7 @@ export function StackedCardsContainer({
setBlockId,
shouldResetBlockId = true,
fullSizeCards = false,
placement = "bottomRight",
}: Readonly<StackedCardsContainerProps>) {
const [hovered, setHovered] = useState(false);
const highlightBorderColor = survey.styling?.overwriteThemeStyling
@@ -179,6 +182,7 @@ export function StackedCardsContainer({
cardWidth={cardWidth}
hovered={hovered}
cardArrangement={cardArrangement}
placement={placement}
/>
);
})

View File

@@ -36,18 +36,35 @@ export const renderSurvey = (props: SurveyContainerProps) => {
throw new Error(`renderSurvey: Element with id ${containerId} not found.`);
}
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
if (props.survey.type === "link") {
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(
h(
I18nProvider,
{ language },
h(RenderSurvey, {
...surveyInlineProps,
})
),
element
);
render(
h(
I18nProvider,
{ language },
h(RenderSurvey, {
...surveyInlineProps,
})
),
element
);
} else {
// For non-link surveys, pass placement through so it can be used in StackedCard
const { darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(
h(
I18nProvider,
{ language },
h(RenderSurvey, {
...surveyInlineProps,
})
),
element
);
}
} else {
const modalContainer = document.createElement("div");
modalContainer.id = "formbricks-modal-container";