Compare commits

..

1 Commits

Author SHA1 Message Date
Matti Nannt
046c56b9dd fix: sync legacy stripe payment methods 2026-03-16 17:02:01 +01:00
23 changed files with 175 additions and 245 deletions

View File

@@ -33,14 +33,14 @@ describe("Password Management", () => {
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
}, 15000);
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
}, 15000);
});
describe("Organization Access", () => {

View File

@@ -34,7 +34,7 @@ describe("Crypto Utils", () => {
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
});
}, 15000);
test("should reject wrong secrets", async () => {
const secret = "test-secret-123";
@@ -43,7 +43,7 @@ describe("Crypto Utils", () => {
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
});
}, 15000);
test("should generate different hashes for the same secret (due to salt)", async () => {
const secret = "test-secret-123";
@@ -64,7 +64,7 @@ describe("Crypto Utils", () => {
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
});
}, 15000);
test("should return false for invalid hash format", async () => {
const secret = "test-secret-123";

View File

@@ -1021,6 +1021,6 @@ describe("updateSurveyDraftAction", () => {
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
}, 15000);
});
});

View File

@@ -159,6 +159,12 @@ describe("organization-billing", () => {
mocks.getCloudPlanFromProduct.mockReturnValue("pro");
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.customersList.mockResolvedValue({ data: [] });
mocks.customersRetrieve.mockResolvedValue({
id: "cus_1",
deleted: false,
invoice_settings: { default_payment_method: null },
default_source: null,
});
mocks.prismaMembershipFindFirst.mockResolvedValue(null);
mocks.productsList.mockResolvedValue({
data: [
@@ -639,6 +645,64 @@ describe("organization-billing", () => {
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
test("syncOrganizationBillingFromStripe marks migrated customers with customer-level payment methods", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: { lastSyncedEventId: null },
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_1",
status: "active",
default_payment_method: null,
billing_cycle_anchor: 1739923200,
items: {
data: [
{
price: {
metadata: {},
product: { id: "prod_pro", metadata: { formbricks_plan: "pro" } },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
mocks.customersRetrieve.mockResolvedValue({
id: "cus_1",
deleted: false,
invoice_settings: { default_payment_method: "pm_legacy_default" },
default_source: null,
});
mocks.entitlementsList.mockResolvedValue({
data: [],
has_more: false,
});
const result = await syncOrganizationBillingFromStripe("org_1");
expect(result?.stripe?.hasPaymentMethod).toBe(true);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stripe: expect.objectContaining({
hasPaymentMethod: true,
}),
}),
})
);
});
test("createPaidPlanCheckoutSession rejects mixed-interval yearly checkout", async () => {
await expect(
createPaidPlanCheckoutSession({

View File

@@ -1107,6 +1107,21 @@ const resolvePendingPlanChange = async (subscription: Stripe.Subscription | null
return null;
};
const resolveHasPaymentMethod = (
subscription: Stripe.Subscription | null,
customer: Stripe.Customer | Stripe.DeletedCustomer
) => {
if (subscription?.default_payment_method != null) {
return true;
}
if (customer.deleted) {
return false;
}
return customer.invoice_settings.default_payment_method != null || customer.default_source != null;
};
export const syncOrganizationBillingFromStripe = async (
organizationId: string,
event?: { id: string; created: number }
@@ -1132,9 +1147,10 @@ export const syncOrganizationBillingFromStripe = async (
return billing;
}
const [subscription, featureLookupKeys] = await Promise.all([
const [subscription, featureLookupKeys, customer] = await Promise.all([
resolveCurrentSubscription(customerId),
listAllActiveEntitlements(customerId),
stripeClient.customers.retrieve(customerId),
]);
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
@@ -1160,7 +1176,7 @@ export const syncOrganizationBillingFromStripe = async (
interval: billingInterval,
subscriptionStatus,
subscriptionId: subscription?.id ?? null,
hasPaymentMethod: subscription?.default_payment_method != null,
hasPaymentMethod: resolveHasPaymentMethod(subscription, customer),
features: featureLookupKeys,
pendingChange,
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),

View File

@@ -92,7 +92,11 @@ function Consent({
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
/>
<span className="fb-text-input-size font-input-weight text-input-text flex-1" dir={dir}>
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
<span
className="font-input-weight text-input-text flex-1"
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}>
{checkboxLabel}
</span>
</label>

View File

@@ -96,7 +96,8 @@ function UploadedFileItem({
<div className="flex flex-col items-center justify-center p-2">
<UploadIcon />
<p
className="fb-text-input-size mt-1 w-full overflow-hidden px-2 text-center overflow-ellipsis whitespace-nowrap text-[var(--foreground)]"
style={{ fontSize: "var(--fb-input-font-size)" }}
className="mt-1 w-full overflow-hidden px-2 text-center overflow-ellipsis whitespace-nowrap text-[var(--foreground)]"
title={file.name}>
{file.name}
</p>
@@ -187,7 +188,10 @@ function UploadArea({
)}
aria-label="Upload files by clicking or dragging them here">
<Upload className="text-input-text h-6" aria-hidden="true" />
<span className="fb-text-input-size text-input-text font-input-weight m-2" id={`${inputId}-label`}>
<span
className="text-input-text font-input-weight m-2"
style={{ fontSize: "var(--fb-input-font-size)" }}
id={`${inputId}-label`}>
{placeholderText}
</span>
<input
@@ -302,7 +306,11 @@ function FileUpload({
<div className="w-full">
{isUploading ? (
<div className="flex animate-pulse items-center justify-center rounded-lg py-4">
<p className="fb-text-input-size text-muted-foreground font-medium">{uploadingText}</p>
<p
className="text-muted-foreground font-medium"
style={{ fontSize: "var(--fb-input-font-size)" }}>
{uploadingText}
</p>
</div>
) : null}

View File

@@ -56,7 +56,8 @@ function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: Readonly<ElementM
<iframe
src={videoUrlWithParams}
title="Question video"
className={cn("aspect-video w-full rounded-md border-0", isLoading ? "opacity-0" : "")}
style={{ border: 0 }}
className={cn("aspect-video w-full rounded-md", isLoading ? "opacity-0" : "")}
onLoad={() => {
setIsLoading(false);
}}

View File

@@ -19,8 +19,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
type={type}
dir={dir}
data-slot="input"
style={{ fontSize: "var(--fb-input-font-size)" }}
className={cn(
"fb-text-input-size",
// Layout and behavior
"flex min-w-0 border transition-[color,box-shadow] outline-none",
// Customizable styles via CSS variables (using Tailwind theme extensions)

View File

@@ -8,14 +8,6 @@ export interface ProgressProps extends Omit<React.ComponentProps<"div">, "childr
function Progress({ className, value, ...props }: Readonly<ProgressProps>): React.JSX.Element {
const progressValue: number = typeof value === "number" ? value : 0;
const indicatorRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (indicatorRef.current) {
indicatorRef.current.style.setProperty("transform", `translateX(-${String(100 - progressValue)}%)`);
}
}, [progressValue]);
return (
<ProgressPrimitive.Root
data-slot="progress"
@@ -23,9 +15,11 @@ function Progress({ className, value, ...props }: Readonly<ProgressProps>): Reac
className={cn("progress-track relative w-full overflow-hidden", className)}
{...props}>
<ProgressPrimitive.Indicator
ref={indicatorRef}
data-slot="progress-indicator"
className="progress-indicator h-full w-full flex-1 transition-all"
style={{
transform: `translateX(-${String(100 - progressValue)}%)`,
}}
/>
</ProgressPrimitive.Root>
);

View File

@@ -10,9 +10,9 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
<div className="relative space-y-2">
<textarea
data-slot="textarea"
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}
className={cn(
"fb-text-input-size",
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View File

@@ -221,13 +221,6 @@
Textarea Scrollbar Styling
Smaller, more subtle scrollbars for textarea elements.
--------------------------------------------------------------------------- */
/* CSP-safe utility: font-size from --fb-input-font-size variable.
Cannot use Tailwind `text-input` because the `input` token is ambiguous
between fontSize.input and colors.input in this config. */
#fbjs .fb-text-input-size {
font-size: var(--fb-input-font-size);
}
#fbjs textarea {
/* Firefox */
scrollbar-width: thin;

View File

@@ -77,6 +77,18 @@ export function SubmitButton({
"border-submit-button-border focus:ring-focus mb-1 flex items-center justify-center border leading-4 shadow-xs hover:opacity-90 focus:ring-2 focus:ring-offset-2 focus:outline-hidden",
"button-custom"
)}
style={{
borderRadius: "var(--fb-button-border-radius)",
backgroundColor: "var(--fb-button-bg-color)",
color: "var(--fb-button-text-color)",
height: "var(--fb-button-height)",
fontSize: "var(--fb-button-font-size)",
fontWeight: "var(--fb-button-font-weight)",
paddingLeft: "var(--fb-button-padding-x)",
paddingRight: "var(--fb-button-padding-x)",
paddingTop: "var(--fb-button-padding-y)",
paddingBottom: "var(--fb-button-padding-y)",
}}
onClick={onClick}
disabled={disabled}>
{buttonLabel || (isLastQuestion ? t("common.finish") : t("common.next"))}

View File

@@ -1,5 +1,3 @@
import { applyCsomStyles } from "@/lib/use-csom-style";
interface AutoCloseProgressBarProps {
autoCloseTimeout: number;
}
@@ -9,12 +7,10 @@ export function AutoCloseProgressBar({ autoCloseTimeout }: AutoCloseProgressBarP
<div className="bg-accent-bg h-2 w-full overflow-hidden">
<div
key={autoCloseTimeout}
className="bg-brand z-20 h-2 w-full"
ref={(el) => {
if (el)
applyCsomStyles(el, {
animation: `shrink-width-to-zero ${autoCloseTimeout.toString()}s linear forwards`,
});
className="bg-brand z-20 h-2"
style={{
animation: `shrink-width-to-zero ${autoCloseTimeout.toString()}s linear forwards`,
width: "100%",
}}
/>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
@@ -8,7 +8,6 @@ import { getI18nLanguage } from "@/lib/i18n-utils";
import i18n from "@/lib/i18n.config";
import { getLanguageDisplayName } from "@/lib/language-display-name";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { applyCsomStyles } from "@/lib/use-csom-style";
import { cn, isRTLLanguage } from "@/lib/utils";
interface LanguageSwitchProps {
@@ -36,21 +35,6 @@ export function LanguageSwitch({
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
const [isHovered, setIsHovered] = useState(false);
const switchBtnRef = useRef<HTMLButtonElement>(null);
const applyHoverStyles = (el: HTMLButtonElement | null, hovered: boolean) => {
if (!el) return;
applyCsomStyles(el, {
backgroundColor: hovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
});
};
// Keep styles in sync when props change
useEffect(() => {
applyHoverStyles(switchBtnRef.current, isHovered);
}, [hoverColorWithOpacity, borderRadius]);
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
const toggleDropdown = () => {
@@ -95,26 +79,21 @@ export function LanguageSwitch({
<button
title={t("common.language_switch")}
type="button"
ref={(el) => {
(switchBtnRef as { current: HTMLButtonElement | null }).current = el;
applyHoverStyles(el, isHovered);
}}
className={cn(
"text-heading relative flex h-8 w-8 items-center justify-center rounded-md focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
)}
style={{
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
}}
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}
aria-label={t("common.language_switch")}
onMouseEnter={() => {
setIsHovered(true);
applyHoverStyles(switchBtnRef.current, true);
}}
onMouseLeave={() => {
setIsHovered(false);
applyHoverStyles(switchBtnRef.current, false);
}}>
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<LanguageIcon />
</button>
{showLanguageDropdown ? (

View File

@@ -1,12 +1,10 @@
import { applyCsomStyles } from "@/lib/use-csom-style";
export function Progress({ progress }: { progress: number }) {
return (
<div className="progress-track h-2 w-full overflow-hidden rounded-none">
<div
className="transition-width progress-indicator z-20 h-full duration-500"
ref={(el) => {
if (el) applyCsomStyles(el, { width: `${Math.floor(progress * 100).toString()}%` });
style={{
width: `${Math.floor(progress * 100).toString()}%`,
}}
/>
</div>

View File

@@ -1,8 +1,7 @@
import { useRef, useState } from "preact/hooks";
import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { CloseIcon } from "@/components/icons/close-icon";
import { mixColor } from "@/lib/color";
import { applyCsomStyles } from "@/lib/use-csom-style";
import { cn } from "@/lib/utils";
interface SurveyCloseButtonProps {
@@ -15,34 +14,19 @@ export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonl
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
const btnRef = useRef<HTMLButtonElement>(null);
const applyStyles = (el: HTMLButtonElement | null, hovered: boolean) => {
if (!el) return;
applyCsomStyles(el, {
backgroundColor: hovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
});
};
return (
<div className="z-1001 flex w-fit items-center">
<button
type="button"
ref={(el) => {
(btnRef as { current: HTMLButtonElement | null }).current = el;
applyStyles(el, isHovered);
}}
onClick={onClose}
onMouseEnter={() => {
setIsHovered(true);
applyStyles(btnRef.current, true);
}}
onMouseLeave={() => {
setIsHovered(false);
applyStyles(btnRef.current, false);
style={{
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn(
"text-heading relative flex h-8 w-8 items-center justify-center p-2 focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
)}

View File

@@ -72,19 +72,17 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
maxHeight = "60dvh";
}
// Apply maxHeight via CSSOM to avoid inline style attributes (CSP compliance)
useEffect(() => {
if (containerRef.current) {
containerRef.current.style.setProperty("max-height", maxHeight);
}
}, [maxHeight]);
return (
<div className="relative">
{!isAtTop && (
<div className="from-survey-bg absolute top-0 right-2 left-0 z-10 h-4 bg-linear-to-b to-transparent" />
)}
<div ref={containerRef} className={cn("bg-survey-bg overflow-auto px-4")}>
<div
ref={containerRef}
style={{
maxHeight,
}}
className={cn("bg-survey-bg overflow-auto px-4")}>
{children}
</div>
{!isAtBottom && (
@@ -93,7 +91,8 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
<button
type="button"
onClick={scrollToBottom}
className="fb-translate-x-center bg-survey-bg hover:border-border focus:ring-brand absolute bottom-2 left-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-transparent shadow-lg transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
style={{ transform: "translateX(-50%)" }}
className="bg-survey-bg hover:border-border focus:ring-brand absolute bottom-2 left-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-transparent shadow-lg transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
aria-label="Scroll to bottom">
<ChevronDownIcon className="text-heading h-5 w-5" />
</button>

View File

@@ -1,11 +1,10 @@
import { MutableRef } from "preact/hooks";
import { useEffect, useMemo, useRef, useState } 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";
import { applyCsomStyles } from "@/lib/use-csom-style";
interface StackedCardProps {
cardRefs: MutableRef<(HTMLDivElement | null)[]>;
@@ -55,14 +54,7 @@ export const StackedCard = ({
};
const getDummyCardContent = () => {
return (
<div
ref={(el) => {
if (el) applyCsomStyles(el, { height: cardHeight });
}}
className="w-full p-6"
/>
);
return <div style={{ height: cardHeight }} className="w-full p-6"></div>;
};
const calculateCardTransform = useMemo(() => {
@@ -98,45 +90,6 @@ export const StackedCard = ({
}
: {};
// Collect all dynamic styles for the card element
const cardStyles = useMemo(
() => ({
zIndex: `${(1000 - dynamicQuestionIndex).toString()}`,
transform: calculateCardTransform(offset),
opacity: `${isHidden ? 0 : (100 - 20 * offset) / 100}`,
height: fullSizeCards ? "100%" : currentCardHeight,
pointerEvents: offset === 0 ? "auto" : "none",
...borderStyles,
...straightCardArrangementStyles,
...getTopBottomStyles(),
}),
[
dynamicQuestionIndex,
calculateCardTransform,
offset,
isHidden,
fullSizeCards,
currentCardHeight,
borderStyles,
straightCardArrangementStyles,
]
);
const contentRef = useRef<HTMLDivElement>(null);
// Apply card styles via CSSOM whenever they change
useEffect(() => {
const el = cardRefs.current[dynamicQuestionIndex];
if (el) applyCsomStyles(el, cardStyles);
}, [cardStyles, dynamicQuestionIndex]);
// Apply content opacity via CSSOM whenever it changes
useEffect(() => {
if (contentRef.current) {
contentRef.current.style.setProperty("opacity", `${contentOpacity}`);
}
}, [contentOpacity]);
useEffect(() => {
setTimeout(() => {
setDelayedOffset(offset);
@@ -154,13 +107,28 @@ export const StackedCard = ({
<div
ref={(el) => {
cardRefs.current[dynamicQuestionIndex] = el;
if (el) applyCsomStyles(el, cardStyles);
}}
id={`questionCard-${dynamicQuestionIndex}`}
data-testid={`questionCard-${dynamicQuestionIndex}`}
key={dynamicQuestionIndex}
className="pointer rounded-custom bg-survey-bg fb-card-transition absolute inset-x-0 overflow-hidden">
<div className="fb-content-transition" ref={contentRef}>
style={{
zIndex: 1000 - dynamicQuestionIndex,
transform: calculateCardTransform(offset),
opacity: isHidden ? 0 : (100 - 20 * offset) / 100,
height: fullSizeCards ? "100%" : currentCardHeight,
transition: "transform 600ms ease-in-out, opacity 600ms ease-in-out, width 600ms ease-in-out",
pointerEvents: offset === 0 ? "auto" : "none",
...borderStyles,
...straightCardArrangementStyles,
...getTopBottomStyles(),
}}
className="pointer rounded-custom bg-survey-bg absolute inset-x-0 overflow-hidden">
<div
style={{
opacity: contentOpacity,
transition: "opacity 300ms ease-in-out",
height: "100%",
}}>
{delayedOffset === 0 ? getCardContent(dynamicQuestionIndex, offset) : getDummyCardContent()}
</div>
</div>

View File

@@ -5,7 +5,6 @@ import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TCardArrangementOptions } from "@formbricks/types/styling";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { applyCsomStyles } from "@/lib/use-csom-style";
import { cn } from "@/lib/utils";
import { StackedCard } from "./stacked-card";
@@ -43,7 +42,6 @@ export function StackedCardsContainer({
? survey.styling?.cardBorderColor?.light
: styling.cardBorderColor?.light;
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const spacerRef = useRef<HTMLDivElement>(null);
const resizeObserver = useRef<ResizeObserver | null>(null);
const [cardHeight, setCardHeight] = useState("auto");
const [cardWidth, setCardWidth] = useState<number>(0);
@@ -136,13 +134,6 @@ export function StackedCardsContainer({
};
}, [blockIdxTemp, cardArrangement, cardRefs]);
// Keep spacer height in sync via CSSOM (CSP compliance)
useEffect(() => {
if (spacerRef.current) {
spacerRef.current.style.setProperty("height", cardHeight);
}
}, [cardHeight]);
// Reset block progress, when card arrangement changes
useEffect(() => {
if (shouldResetBlockId) {
@@ -161,15 +152,13 @@ export function StackedCardsContainer({
onMouseLeave={() => {
setHovered(false);
}}>
<div ref={spacerRef} />
<div style={{ height: cardHeight }} />
{cardArrangement === "simple" ? (
<div
id={`questionCard-${blockIdxTemp.toString()}`}
data-testid={`questionCard-${blockIdxTemp.toString()}`}
className={cn("bg-survey-bg w-full overflow-hidden", fullSizeCards ? "h-full" : "")}
ref={(el) => {
if (el) applyCsomStyles(el, borderStyles);
}}>
style={borderStyles}>
{getCardContent(blockIdxTemp, 0)}
</div>
) : (

View File

@@ -70,7 +70,7 @@ export function SurveyContainer({
if (!isModal) {
return (
<div id="fbjs" className="formbricks-form fb-full-size" dir={dir}>
<div id="fbjs" className="formbricks-form" style={{ height: "100%", width: "100%" }} dir={dir}>
{children}
</div>
);

View File

@@ -1,42 +0,0 @@
import { useCallback } from "preact/hooks";
/**
* Returns a ref callback that applies style properties via CSSOM (element.style.setProperty)
* instead of inline `style` attributes. This bypasses CSP `style-src` restrictions because
* the browser treats CSSOM mutations as script-driven DOM manipulation, not inline styles.
*
* Usage:
* const csomRef = useCsomStyle({ zIndex: "1000", transform: "translateY(25%)" });
* <div ref={csomRef} />
*/
export function useCsomStyle(
styles: Record<string, string | number | undefined | null>
): (el: HTMLElement | null) => void {
return useCallback(
(el: HTMLElement | null) => {
if (!el) return;
for (const [prop, value] of Object.entries(styles)) {
if (value === undefined || value === null) continue;
// Convert camelCase to kebab-case (e.g. zIndex → z-index, pointerEvents → pointer-events)
const kebab = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
el.style.setProperty(kebab, String(value));
}
},
[JSON.stringify(styles)]
);
}
/**
* Applies styles via CSSOM imperatively (for use in ref callbacks that also need
* to store the element reference, e.g. cardRefs).
*/
export function applyCsomStyles(
el: HTMLElement,
styles: Record<string, string | number | undefined | null>
): void {
for (const [prop, value] of Object.entries(styles)) {
if (value === undefined || value === null) continue;
const kebab = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
el.style.setProperty(kebab, String(value));
}
}

View File

@@ -173,37 +173,4 @@
#fbjs .grecaptcha-badge {
visibility: hidden;
}
/* Submit button base styles using CSS variables (CSP-safe, no inline style needed) */
#fbjs .button-custom {
border-radius: var(--fb-button-border-radius, var(--fb-border-radius));
background-color: var(--fb-button-bg-color, var(--fb-brand-color));
color: var(--fb-button-text-color, var(--fb-brand-text-color));
height: var(--fb-button-height, auto);
font-size: var(--fb-button-font-size, inherit);
font-weight: var(--fb-button-font-weight, inherit);
padding-left: var(--fb-button-padding-x, 1.5rem);
padding-right: var(--fb-button-padding-x, 1.5rem);
padding-top: var(--fb-button-padding-y, 0.375rem);
padding-bottom: var(--fb-button-padding-y, 0.375rem);
}
/* CSP-safe utility classes (avoid inline style attributes) */
#fbjs .fb-full-size {
height: 100%;
width: 100%;
}
#fbjs .fb-translate-x-center {
transform: translateX(-50%);
}
#fbjs .fb-content-transition {
transition: opacity 300ms ease-in-out;
height: 100%;
}
#fbjs .fb-card-transition {
transition: transform 600ms ease-in-out, opacity 600ms ease-in-out, width 600ms ease-in-out;
}