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 { CheckCircle2Icon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TResponseWithQuotas } from "@formbricks/types/responses"; import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -67,6 +68,16 @@ export const SingleResponseCardBody = ({
<VerifiedEmail responseData={response.data} /> <VerifiedEmail responseData={response.data} />
)} )}
{elements.map((question) => { {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) => const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id) skippedQuestionElement.includes(question.id)
); );

View File

@@ -425,11 +425,19 @@ export const SurveyMenuBar = ({
const segment = await handleSegmentUpdate(); const segment = await handleSegmentUpdate();
clearSurveyLocalStorage(); clearSurveyLocalStorage();
await updateSurveyAction({ const publishResult = await updateSurveyAction({
...localSurvey, ...localSurvey,
status, status,
segment, segment,
}); });
if (!publishResult?.data) {
const errorMessage = getFormattedErrorMessage(publishResult);
toast.error(errorMessage);
setIsSurveyPublishing(false);
return;
}
setIsSurveyPublishing(false); setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation // Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true; isSuccessfullySavedRef.current = true;
@@ -467,7 +475,7 @@ export const SurveyMenuBar = ({
/> />
</div> </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} /> <AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
{!isStorageConfigured && ( {!isStorageConfigured && (
<div> <div>

View File

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

View File

@@ -170,7 +170,7 @@ export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: stri
const selectedLanguage = survey.languages.find((surveyLanguage) => { const selectedLanguage = survey.languages.find((surveyLanguage) => {
return ( return (
surveyLanguage.language.code === language.toLowerCase() || surveyLanguage.language.code.toLowerCase() === language.toLowerCase() ||
surveyLanguage.language.alias?.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 { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header"; import { ElementHeader } from "@/components/general/element-header";
import { Label } from "@/components/general/label"; import { Label } from "@/components/general/label";
import { cn } from "@/lib/utils"; import { cn, getRTLScaleOptionClasses } from "@/lib/utils";
interface NPSProps { interface NPSProps {
/** Unique identifier for the element container */ /** Unique identifier for the element container */
@@ -97,18 +97,9 @@ function NPS({
const isLast = number === 10; // Last option is 10 const isLast = number === 10; // Last option is 10
const isFirst = number === 0; // First option is 0 const isFirst = number === 0; // First option is 0
// Determine border radius and border classes // Use CSS logical properties for RTL-aware borders and border radius
// Use right border for all items to create separators, left border only on first item // The fieldset's dir attribute automatically handles direction
let borderRadiusClasses = ""; const { borderRadiusClasses, borderClasses } = getRTLScaleOptionClasses(isFirst, isLast);
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
}
return ( return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
@@ -183,7 +174,7 @@ function NPS({
{/* NPS Options */} {/* NPS Options */}
<div className="relative"> <div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} /> <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> <legend className="sr-only">NPS rating options</legend>
<div className="flex w-full">{npsOptions.map((number) => renderNPSOption(number))}</div> <div className="flex w-full">{npsOptions.map((number) => renderNPSOption(number))}</div>

View File

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

View File

@@ -35,3 +35,29 @@ export const stripInlineStyles = (html: string): string => {
KEEP_CONTENT: true, 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, isSpamProtectionEnabled,
dir = "auto", dir = "auto",
setDir, setDir,
placement,
}: SurveyContainerProps) { }: SurveyContainerProps) {
let apiClient: ApiClient | null = null; let apiClient: ApiClient | null = null;
@@ -916,6 +917,7 @@ export function Survey({
setBlockId={setBlockId} setBlockId={setBlockId}
shouldResetBlockId={shouldResetQuestionId} shouldResetBlockId={shouldResetQuestionId}
fullSizeCards={fullSizeCards} fullSizeCards={fullSizeCards}
placement={placement}
/> />
); );
} }

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { JSX } from "react"; import type { JSX } from "react";
import { type TPlacement } from "@formbricks/types/common";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TProjectStyling } from "@formbricks/types/project"; import { type TProjectStyling } from "@formbricks/types/project";
import { type TCardArrangementOptions } from "@formbricks/types/styling"; import { type TCardArrangementOptions } from "@formbricks/types/styling";
@@ -19,6 +20,7 @@ interface StackedCardsContainerProps {
setBlockId: (blockId: string) => void; setBlockId: (blockId: string) => void;
shouldResetBlockId?: boolean; shouldResetBlockId?: boolean;
fullSizeCards: boolean; fullSizeCards: boolean;
placement?: TPlacement;
} }
export function StackedCardsContainer({ export function StackedCardsContainer({
@@ -30,6 +32,7 @@ export function StackedCardsContainer({
setBlockId, setBlockId,
shouldResetBlockId = true, shouldResetBlockId = true,
fullSizeCards = false, fullSizeCards = false,
placement = "bottomRight",
}: Readonly<StackedCardsContainerProps>) { }: Readonly<StackedCardsContainerProps>) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const highlightBorderColor = survey.styling?.overwriteThemeStyling const highlightBorderColor = survey.styling?.overwriteThemeStyling
@@ -179,6 +182,7 @@ export function StackedCardsContainer({
cardWidth={cardWidth} cardWidth={cardWidth}
hovered={hovered} hovered={hovered}
cardArrangement={cardArrangement} 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.`); 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( render(
h( h(
I18nProvider, I18nProvider,
{ language }, { language },
h(RenderSurvey, { h(RenderSurvey, {
...surveyInlineProps, ...surveyInlineProps,
}) })
), ),
element 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 { } else {
const modalContainer = document.createElement("div"); const modalContainer = document.createElement("div");
modalContainer.id = "formbricks-modal-container"; modalContainer.id = "formbricks-modal-container";