mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 05:17:49 -05:00
Compare commits
9 Commits
4.8.4-rc.1
...
4.8.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b679403d | |||
| 4a5404557b | |||
| fbb529d066 | |||
| e07b6fb3d1 | |||
| 3996f89f75 | |||
| 86f852ee4b | |||
| 5bf884d529 | |||
| 351cd75e57 | |||
| 2e36f0c590 |
@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
|
|||||||
|
|
||||||
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
|
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
|
||||||
if (!surveyLanguages?.length || !languageCode) return "default";
|
if (!surveyLanguages?.length || !languageCode) return "default";
|
||||||
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
|
const language = surveyLanguages.find(
|
||||||
|
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
|
||||||
|
);
|
||||||
return language?.default ? "default" : language?.language.code || "default";
|
return language?.default ? "default" : language?.language.code || "default";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,10 +106,7 @@ describe("billing actions", () => {
|
|||||||
});
|
});
|
||||||
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
||||||
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
||||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
|
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||||
"org_1",
|
|
||||||
"start-hobby"
|
|
||||||
);
|
|
||||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
@@ -128,10 +125,7 @@ describe("billing actions", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
||||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
|
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||||
"org_1",
|
|
||||||
"start-hobby"
|
|
||||||
);
|
|
||||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
@@ -145,7 +139,7 @@ describe("billing actions", () => {
|
|||||||
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
|
||||||
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
|
||||||
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
|
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
|
||||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
|
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
@@ -165,7 +159,7 @@ describe("billing actions", () => {
|
|||||||
|
|
||||||
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
|
||||||
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
|
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
|
||||||
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
|
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
|
||||||
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
|
|||||||
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
|
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
|
||||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createProTrialSubscription(parsedInput.organizationId, customerId);
|
await createProTrialSubscription(parsedInput.organizationId, customerId);
|
||||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
|
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
|
||||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
|||||||
await handleSetupCheckoutCompleted(event.data.object, stripe);
|
await handleSetupCheckoutCompleted(event.data.object, stripe);
|
||||||
}
|
}
|
||||||
|
|
||||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
|
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
|
||||||
await syncOrganizationBillingFromStripe(organizationId, {
|
await syncOrganizationBillingFromStripe(organizationId, {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
created: event.created,
|
created: event.created,
|
||||||
|
|||||||
@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
|
|||||||
items: [{ price: "price_hobby_monthly", quantity: 1 }],
|
items: [{ price: "price_hobby_monthly", quantity: 1 }],
|
||||||
metadata: { organizationId: "org_1" },
|
metadata: { organizationId: "org_1" },
|
||||||
},
|
},
|
||||||
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
|
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
|
||||||
);
|
);
|
||||||
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
|
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
|
||||||
where: { organizationId: "org_1" },
|
where: { organizationId: "org_1" },
|
||||||
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
|
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
|
||||||
|
|
||||||
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
|
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
|
||||||
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
|
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -458,18 +458,21 @@ const resolvePendingChangeEffectiveAt = (
|
|||||||
const ensureHobbySubscription = async (
|
const ensureHobbySubscription = async (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
customerId: string,
|
customerId: string,
|
||||||
idempotencySuffix: string
|
subscriptionCount: number
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!stripeClient) return;
|
if (!stripeClient) return;
|
||||||
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
|
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
|
||||||
|
|
||||||
|
// Include subscriptionCount so the key is stable across concurrent calls (same
|
||||||
|
// count → same key → Stripe deduplicates) but changes after a cancellation
|
||||||
|
// (count increases → new key → allows legitimate re-creation).
|
||||||
await stripeClient.subscriptions.create(
|
await stripeClient.subscriptions.create(
|
||||||
{
|
{
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
items: hobbyItems,
|
items: hobbyItems,
|
||||||
metadata: { organizationId },
|
metadata: { organizationId },
|
||||||
},
|
},
|
||||||
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
|
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1264,8 +1267,7 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||||
organizationId: string,
|
organizationId: string
|
||||||
idempotencySuffix = "reconcile"
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const client = stripeClient;
|
const client = stripeClient;
|
||||||
if (!IS_FORMBRICKS_CLOUD || !client) return;
|
if (!IS_FORMBRICKS_CLOUD || !client) return;
|
||||||
@@ -1313,11 +1315,26 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
hobbySubscriptions.map(({ subscription }) =>
|
hobbySubscriptions.map(async ({ subscription }) => {
|
||||||
client.subscriptions.cancel(subscription.id, {
|
try {
|
||||||
prorate: false,
|
await client.subscriptions.cancel(subscription.id, {
|
||||||
})
|
prorate: false,
|
||||||
)
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Stripe.errors.StripeInvalidRequestError &&
|
||||||
|
err.statusCode === 404 &&
|
||||||
|
err.code === "resource_missing"
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ subscriptionId: subscription.id, organizationId },
|
||||||
|
"Subscription already deleted, skipping cancel"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1327,12 +1344,14 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
|||||||
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
|
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
|
||||||
const freshSubscriptions = await client.subscriptions.list({
|
const freshSubscriptions = await client.subscriptions.list({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
status: "active",
|
status: "all",
|
||||||
limit: 1,
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (freshSubscriptions.data.length === 0) {
|
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
|
||||||
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
|
|
||||||
|
if (freshActive.length === 0) {
|
||||||
|
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1340,6 +1359,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
|||||||
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
|
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
|
||||||
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
|
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
|
||||||
await ensureStripeCustomerForOrganization(organizationId);
|
await ensureStripeCustomerForOrganization(organizationId);
|
||||||
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
|
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
|
||||||
await syncOrganizationBillingFromStripe(organizationId);
|
await syncOrganizationBillingFromStripe(organizationId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -346,8 +346,8 @@ export const MultipleChoiceElementForm = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="mt-2 flex items-center justify-between space-x-2">
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{specialChoices.map((specialChoice) => {
|
{specialChoices.map((specialChoice) => {
|
||||||
if (element.choices.some((c) => c.id === specialChoice.id)) return null;
|
if (element.choices.some((c) => c.id === specialChoice.id)) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const SurveyMenuBar = ({
|
|||||||
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
|
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
|
||||||
const isSuccessfullySavedRef = useRef(false);
|
const isSuccessfullySavedRef = useRef(false);
|
||||||
const isAutoSavingRef = useRef(false);
|
const isAutoSavingRef = useRef(false);
|
||||||
|
const isSurveyPublishingRef = useRef(false);
|
||||||
|
|
||||||
// Refs for interval-based auto-save (to access current values without re-creating interval)
|
// Refs for interval-based auto-save (to access current values without re-creating interval)
|
||||||
const localSurveyRef = useRef(localSurvey);
|
const localSurveyRef = useRef(localSurvey);
|
||||||
@@ -269,8 +270,8 @@ export const SurveyMenuBar = ({
|
|||||||
// Skip if tab is not visible (no computation, no API calls for background tabs)
|
// Skip if tab is not visible (no computation, no API calls for background tabs)
|
||||||
if (document.hidden) return;
|
if (document.hidden) return;
|
||||||
|
|
||||||
// Skip if already saving (manual or auto)
|
// Skip if already saving, publishing, or auto-saving
|
||||||
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
|
if (isAutoSavingRef.current || isSurveySavingRef.current || isSurveyPublishingRef.current) return;
|
||||||
|
|
||||||
// Check for changes using refs (avoids re-creating interval on every change)
|
// Check for changes using refs (avoids re-creating interval on every change)
|
||||||
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
|
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
|
||||||
@@ -289,10 +290,19 @@ export const SurveyMenuBar = ({
|
|||||||
} as unknown as TSurveyDraft);
|
} as unknown as TSurveyDraft);
|
||||||
|
|
||||||
if (updatedSurveyResponse?.data) {
|
if (updatedSurveyResponse?.data) {
|
||||||
|
const savedData = updatedSurveyResponse.data;
|
||||||
|
|
||||||
|
// If the segment changed on the server (e.g., private segment was deleted when
|
||||||
|
// switching from app to link type), update localSurvey to prevent stale segment
|
||||||
|
// references when publishing
|
||||||
|
if (!isEqual(localSurveyRef.current.segment, savedData.segment)) {
|
||||||
|
setLocalSurvey({ ...localSurveyRef.current, segment: savedData.segment });
|
||||||
|
}
|
||||||
|
|
||||||
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
|
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
|
||||||
// This keeps the UI stable while still tracking that changes have been saved.
|
// This keeps the UI stable while still tracking that changes have been saved.
|
||||||
// The comparison uses refs, so this prevents unnecessary re-saves.
|
// The comparison uses refs, so this prevents unnecessary re-saves.
|
||||||
surveyRef.current = { ...updatedSurveyResponse.data };
|
surveyRef.current = { ...savedData };
|
||||||
isSuccessfullySavedRef.current = true;
|
isSuccessfullySavedRef.current = true;
|
||||||
setLastAutoSaved(new Date());
|
setLastAutoSaved(new Date());
|
||||||
}
|
}
|
||||||
@@ -417,11 +427,13 @@ export const SurveyMenuBar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSurveyPublish = async () => {
|
const handleSurveyPublish = async () => {
|
||||||
|
isSurveyPublishingRef.current = true;
|
||||||
setIsSurveyPublishing(true);
|
setIsSurveyPublishing(true);
|
||||||
|
|
||||||
const isSurveyValidatedWithZod = validateSurveyWithZod();
|
const isSurveyValidatedWithZod = validateSurveyWithZod();
|
||||||
|
|
||||||
if (!isSurveyValidatedWithZod) {
|
if (!isSurveyValidatedWithZod) {
|
||||||
|
isSurveyPublishingRef.current = false;
|
||||||
setIsSurveyPublishing(false);
|
setIsSurveyPublishing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -429,6 +441,7 @@ export const SurveyMenuBar = ({
|
|||||||
try {
|
try {
|
||||||
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
|
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
|
||||||
if (!isSurveyValidResult) {
|
if (!isSurveyValidResult) {
|
||||||
|
isSurveyPublishingRef.current = false;
|
||||||
setIsSurveyPublishing(false);
|
setIsSurveyPublishing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -445,10 +458,12 @@ export const SurveyMenuBar = ({
|
|||||||
if (!publishResult?.data) {
|
if (!publishResult?.data) {
|
||||||
const errorMessage = getFormattedErrorMessage(publishResult);
|
const errorMessage = getFormattedErrorMessage(publishResult);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
|
isSurveyPublishingRef.current = false;
|
||||||
setIsSurveyPublishing(false);
|
setIsSurveyPublishing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSurveyPublishingRef.current = false;
|
||||||
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;
|
||||||
@@ -456,6 +471,7 @@ export const SurveyMenuBar = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(t("environments.surveys.edit.error_publishing_survey"));
|
toast.error(t("environments.surveys.edit.error_publishing_survey"));
|
||||||
|
isSurveyPublishingRef.current = false;
|
||||||
setIsSurveyPublishing(false);
|
setIsSurveyPublishing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ function getLanguageCode(langParam: string | undefined, survey: TSurvey): string
|
|||||||
|
|
||||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||||
return (
|
return (
|
||||||
surveyLanguage.language.code === langParam.toLowerCase() ||
|
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
|
||||||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
|
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function LanguageIndicator({
|
|||||||
<button
|
<button
|
||||||
aria-expanded={showLanguageDropdown}
|
aria-expanded={showLanguageDropdown}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
|
className="relative z-20 flex max-w-20 items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
|
||||||
onClick={toggleDropdown}
|
onClick={toggleDropdown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
type="button">
|
type="button">
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function LanguageSwitch({
|
|||||||
handleI18nLanguage(calculatedLanguageCode);
|
handleI18nLanguage(calculatedLanguageCode);
|
||||||
|
|
||||||
if (setDir) {
|
if (setDir) {
|
||||||
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
|
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "ltr";
|
||||||
setDir?.(calculateDir);
|
setDir?.(calculateDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ export function RenderSurvey(props: SurveyContainerProps) {
|
|||||||
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
||||||
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
|
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "ltr");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
||||||
setDir(isRTL ? "rtl" : "auto");
|
setDir(isRTL ? "rtl" : "ltr");
|
||||||
}, [props.languageCode, props.survey]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only recalculate direction when languageCode changes, not on survey auto-save
|
||||||
|
}, [props.languageCode]);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (onFinishedTimeoutRef.current) {
|
if (onFinishedTimeoutRef.current) {
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import { ComponentChildren } from "preact";
|
import { ComponentChildren } from "preact";
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect, useRef } from "preact/hooks";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import i18n from "../../lib/i18n.config";
|
import i18n from "../../lib/i18n.config";
|
||||||
|
|
||||||
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
|
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
|
||||||
|
const isFirstRender = useRef(true);
|
||||||
|
const prevLanguage = useRef(language);
|
||||||
|
|
||||||
// Set language synchronously on initial render so children get the correct translations immediately.
|
// Set language synchronously on initial render so children get the correct translations immediately.
|
||||||
// This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
|
// This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
|
||||||
if (i18n.language !== language) {
|
// On subsequent renders, skip this to avoid overriding language changes made by the user via LanguageSwitch.
|
||||||
i18n.changeLanguage(language);
|
if (isFirstRender.current) {
|
||||||
}
|
|
||||||
|
|
||||||
// Handle language prop changes after initial render
|
|
||||||
useEffect(() => {
|
|
||||||
if (i18n.language !== language) {
|
if (i18n.language !== language) {
|
||||||
i18n.changeLanguage(language);
|
i18n.changeLanguage(language);
|
||||||
}
|
}
|
||||||
|
isFirstRender.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update language when the prop itself changes, not when i18n was changed internally by user action
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevLanguage.current !== language) {
|
||||||
|
i18n.changeLanguage(language);
|
||||||
|
prevLanguage.current = language;
|
||||||
|
}
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
// work around for react-i18next not supporting preact
|
// work around for react-i18next not supporting preact
|
||||||
|
|||||||
@@ -41,9 +41,16 @@ export const getLocalizedValue = (
|
|||||||
* This ensures translations are always available, even when called from API routes
|
* This ensures translations are always available, even when called from API routes
|
||||||
*/
|
*/
|
||||||
export const getTranslations = (languageCode: string): TFunction => {
|
export const getTranslations = (languageCode: string): TFunction => {
|
||||||
|
// "default" is a Formbricks-internal language identifier, not a valid i18next locale.
|
||||||
|
// When "default" is passed, use the current i18n language (which was already resolved
|
||||||
|
// to a real locale by the I18nProvider or LanguageSwitch). Calling
|
||||||
|
// i18n.changeLanguage("default") would cause i18next to fall back to "en", resetting
|
||||||
|
// the user's selected language (see issue #7515).
|
||||||
|
const resolvedCode = languageCode === "default" ? i18n.language : languageCode;
|
||||||
|
|
||||||
// Ensure the language is set (i18n.changeLanguage is synchronous when resources are already loaded)
|
// Ensure the language is set (i18n.changeLanguage is synchronous when resources are already loaded)
|
||||||
if (i18n.language !== languageCode) {
|
if (i18n.language !== resolvedCode) {
|
||||||
i18n.changeLanguage(languageCode);
|
i18n.changeLanguage(resolvedCode);
|
||||||
}
|
}
|
||||||
return i18n.getFixedT(languageCode);
|
return i18n.getFixedT(resolvedCode);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user