Merge branch 'main' into hmaan-displayId

This commit is contained in:
pandeymangg
2025-07-04 14:19:05 +05:30
32 changed files with 968 additions and 444 deletions

View File

@@ -210,6 +210,8 @@ UNKEY_ROOT_KEY=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
# SENTRY_ENVIRONMENT=
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"

View File

@@ -0,0 +1,121 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
required: true
sentry_auth_token:
description: 'Sentry authentication token'
required: true
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
shell: bash
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
exit 1
fi
echo "✅ Sentry auth token validated successfully"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
# Capture the current exit code to preserve it
local original_exit_code=$?
echo "🧹 Cleaning up Docker container..."
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
# Extract .next directory containing sourcemaps
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
# Verify sourcemaps exist
if [ ! -d "./extracted-next/static/chunks" ]; then
echo "❌ Error: .next/static/chunks directory not found in Docker image"
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
exit 1
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
exit 1
fi
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: production
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
- name: Clean up extracted files
shell: bash
if: always()
run: |
set -euo pipefail
# Clean up extracted files
rm -rf ./extracted-next
echo "🧹 Cleaned up extracted files"

View File

@@ -32,3 +32,25 @@ jobs:
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -0,0 +1,46 @@
name: Upload Sentry Sourcemaps (Manual)
on:
workflow_dispatch:
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
type: string
release_version:
description: "Release version (e.g., v1.2.3)"
required: true
type: string
tag_version:
description: "Docker image tag (leave empty to use release_version)"
required: false
type: string
permissions:
contents: read
jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -31,6 +31,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SENTRY_RELEASE: "mock-sentry-release",
SENTRY_ENVIRONMENT: "mock-sentry-environment",
}));
vi.mock("@/tolgee/language", () => ({
@@ -59,9 +61,18 @@ vi.mock("@/tolgee/client", () => ({
}));
vi.mock("@/app/sentry/SentryProvider", () => ({
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
SentryProvider: ({
children,
sentryDsn,
sentryRelease,
}: {
children: React.ReactNode;
sentryDsn?: string;
sentryRelease?: string;
}) => (
<div data-testid="sentry-provider">
SentryProvider: {sentryDsn}
{sentryRelease && ` - Release: ${sentryRelease}`}
{children}
</div>
),

View File

@@ -1,5 +1,5 @@
import { SentryProvider } from "@/app/sentry/SentryProvider";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
@@ -25,7 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}>
<SentryProvider
sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE}
sentryEnvironment={SENTRY_ENVIRONMENT}
isEnabled={IS_PRODUCTION}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>

View File

@@ -48,6 +48,24 @@ describe("SentryProvider", () => {
);
});
test("calls Sentry.init with sentryRelease when provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
const testRelease = "v1.2.3";
render(
<SentryProvider sentryDsn={sentryDsn} sentryRelease={testRelease} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: sentryDsn,
release: testRelease,
})
);
});
test("does not call Sentry.init when sentryDsn is not provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);

View File

@@ -6,14 +6,24 @@ import { useEffect } from "react";
interface SentryProviderProps {
children: React.ReactNode;
sentryDsn?: string;
sentryRelease?: string;
sentryEnvironment?: string;
isEnabled?: boolean;
}
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => {
export const SentryProvider = ({
children,
sentryDsn,
sentryRelease,
sentryEnvironment,
isEnabled,
}: SentryProviderProps) => {
useEffect(() => {
if (sentryDsn && isEnabled) {
Sentry.init({
dsn: sentryDsn,
release: sentryRelease,
environment: sentryEnvironment,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,

View File

@@ -233,8 +233,8 @@ export enum STRIPE_PROJECT_NAMES {
}
export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MONTHLY = "formbricks_startup_monthly",
STARTUP_YEARLY = "formbricks_startup_yearly",
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
SCALE_MONTHLY = "formbricks_scale_monthly",
SCALE_YEARLY = "formbricks_scale_yearly",
}
@@ -273,6 +273,24 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
// Use the app version for Sentry release (updated during build in production)
// Fallback to environment variable if package.json is not accessible
export const SENTRY_RELEASE = (() => {
if (process.env.NODE_ENV !== "production") {
return undefined;
}
// Try to read from package.json with proper error handling
try {
const pkg = require("../package.json");
return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`;
} catch {
// If package.json can't be read (e.g., in some deployment scenarios),
// return undefined and let Sentry work without release tracking
return undefined;
}
})();
export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT;
export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";

View File

@@ -127,6 +127,7 @@ export const env = createEnv({
.string()
.transform((val) => parseInt(val))
.optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
},
/*
@@ -225,5 +226,6 @@ export const env = createEnv({
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
},
});

View File

@@ -316,6 +316,7 @@
"remove": "Entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
@@ -979,63 +980,53 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"10000_monthly_responses": "10,000 monatliche Antworten",
"1500_monthly_responses": "1,500 monatliche Antworten",
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer",
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
"1000_monthly_responses": "1,000 monatliche Antworten",
"1_project": "1 Projekt",
"2000_contacts": "2,000 Kontakte",
"3_projects": "3 Projekte",
"5000_monthly_responses": "5,000 monatliche Antworten",
"5_projects": "5 Projekte",
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
"advanced_targeting": "Erweitertes Targeting",
"7500_contacts": "7,500 Kontakte",
"all_integrations": "Alle Integrationen",
"all_surveying_features": "Alle Umfragefunktionen",
"annually": "Jährlich",
"api_webhooks": "API & Webhooks",
"app_surveys": "In-app Umfragen",
"contact_us": "Kontaktiere uns",
"attribute_based_targeting": "Attributbasiertes Targeting",
"current": "aktuell",
"current_plan": "Aktueller Plan",
"current_tier_limit": "Aktuelles Limit",
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit",
"custom": "Benutzerdefiniert & Skalierung",
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
"custom_project_limit": "Benutzerdefiniertes Projektlimit",
"customer_success_manager": "Customer Success Manager",
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
"email_support": "E-Mail-Support",
"enterprise": "Enterprise",
"email_follow_ups": "E-Mail Follow-ups",
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
"everything_in_free": "Alles in 'Free''",
"everything_in_scale": "Alles in 'Scale''",
"everything_in_startup": "Alles in 'Startup''",
"free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis",
"get_in_touch": "Kontaktiere uns",
"hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)",
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
"manage_card_details": "Karteninformationen verwalten",
"manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"multi_language_surveys": "Mehrsprachige Umfragen",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"priority_support": "Priorisierter Support",
"remove_branding": "Branding entfernen",
"say_hi": "Sag Hi!",
"scale": "Scale",
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder",
"technical_onboarding": "Technische Einführung",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
"unlimited_miu": "Unbegrenzte MIU",
"unlimited_projects": "Unbegrenzte Projekte",
"unlimited_responses": "Unbegrenzte Antworten",
@@ -1230,6 +1221,7 @@
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": {

View File

@@ -316,6 +316,7 @@
"remove": "Remove",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"response": "Response",
@@ -979,63 +980,53 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"10000_monthly_responses": "10000 Monthly Responses",
"1500_monthly_responses": "1500 Monthly Responses",
"2000_monthly_identified_users": "2000 Monthly Identified Users",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"1000_monthly_responses": "Monthly 1,000 Responses",
"1_project": "1 Project",
"2000_contacts": "2,000 Contacts",
"3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses",
"5_projects": "5 Projects",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
"7500_contacts": "7,500 Contacts",
"all_integrations": "All Integrations",
"all_surveying_features": "All surveying features",
"annually": "Annually",
"api_webhooks": "API & Webhooks",
"app_surveys": "App Surveys",
"contact_us": "Contact Us",
"attribute_based_targeting": "Attribute-based Targeting",
"current": "Current",
"current_plan": "Current Plan",
"current_tier_limit": "Current Tier Limit",
"custom_miu_limit": "Custom MIU limit",
"custom": "Custom & Scale",
"custom_contacts_limit": "Custom Contacts Limit",
"custom_project_limit": "Custom Project Limit",
"customer_success_manager": "Customer Success Manager",
"custom_response_limit": "Custom Response Limit",
"email_embedded_surveys": "Email Embedded Surveys",
"email_support": "Email Support",
"enterprise": "Enterprise",
"email_follow_ups": "Email Follow-ups",
"enterprise_description": "Premium support and custom limits.",
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
"everything_in_free": "Everything in Free",
"everything_in_scale": "Everything in Scale",
"everything_in_startup": "Everything in Startup",
"free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free",
"get_in_touch": "Get in touch",
"hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)",
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
"manage_card_details": "Manage Card Details",
"manage_subscription": "Manage Subscription",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"multi_language_surveys": "Multi-Language Surveys",
"per_month": "per month",
"per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"priority_support": "Priority Support",
"remove_branding": "Remove Branding",
"say_hi": "Say Hi!",
"scale": "Scale",
"scale_description": "Advanced features for scaling your business.",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles",
"technical_onboarding": "Technical Onboarding",
"unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_apps_websites": "Unlimited Apps & Websites",
"unlimited_miu": "Unlimited MIU",
"unlimited_projects": "Unlimited Projects",
"unlimited_responses": "Unlimited Responses",
@@ -1230,6 +1221,7 @@
"copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully!",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {

View File

@@ -316,6 +316,7 @@
"remove": "Retirer",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Demander la tarification",
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"response": "Réponse",
@@ -979,63 +980,53 @@
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Réponses Mensuelles",
"1500_monthly_responses": "1500 Réponses Mensuelles",
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"1000_monthly_responses": "1000 Réponses Mensuelles",
"1_project": "1 Projet",
"2000_contacts": "2 000 Contacts",
"3_projects": "3 Projets",
"5000_monthly_responses": "5,000 Réponses Mensuelles",
"5_projects": "5 Projets",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
"7500_contacts": "7 500 Contacts",
"all_integrations": "Toutes les intégrations",
"all_surveying_features": "Tous les outils d'arpentage",
"annually": "Annuellement",
"api_webhooks": "API et Webhooks",
"app_surveys": "Sondages d'application",
"contact_us": "Contactez-nous",
"attribute_based_targeting": "Ciblage basé sur les attributs",
"current": "Actuel",
"current_plan": "Plan actuel",
"current_tier_limit": "Limite de niveau actuel",
"custom_miu_limit": "Limite MIU personnalisé",
"custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Limite de contacts personnalisé",
"custom_project_limit": "Limite de projet personnalisé",
"customer_success_manager": "Responsable de la réussite client",
"custom_response_limit": "Limite de réponse personnalisé",
"email_embedded_surveys": "Sondages intégrés par e-mail",
"email_support": "Support par e-mail",
"enterprise": "Entreprise",
"email_follow_ups": "Relances par e-mail",
"enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Tout est gratuit",
"everything_in_scale": "Tout à l'échelle",
"everything_in_startup": "Tout dans le Startup",
"free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits",
"get_in_touch": "Prenez contact",
"hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"link_surveys": "Sondages par lien (partageables)",
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte",
"manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs Identifiés Mensuels",
"multi_language_surveys": "Sondages multilingues",
"per_month": "par mois",
"per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Soutien premium avec SLA",
"priority_support": "Soutien Prioritaire",
"remove_branding": "Supprimer la marque",
"say_hi": "Dis bonjour !",
"scale": "Échelle",
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
"startup": "Startup",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Rôles d'accès d'équipe",
"technical_onboarding": "Intégration technique",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_apps_websites": "Applications et sites Web illimités",
"unlimited_miu": "MIU Illimité",
"unlimited_projects": "Projets illimités",
"unlimited_responses": "Réponses illimitées",
@@ -1230,6 +1221,7 @@
"copy_survey_description": "Copier cette enquête dans un autre environnement",
"copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {

View File

@@ -316,6 +316,7 @@
"remove": "remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"response": "Resposta",
@@ -979,63 +980,53 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"1000_monthly_responses": "1000 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2.000 Contatos",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
"7500_contacts": "7.500 Contatos",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todos os recursos de levantamento",
"annually": "anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Pesquisas de App",
"contact_us": "Fale Conosco",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual de Nível",
"custom_miu_limit": "Limite MIU personalizado",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contatos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gerente de Sucesso do Cliente",
"custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"email_follow_ups": "Acompanhamentos por Email",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
"everything_in_free": "Tudo de graça",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis",
"get_in_touch": "Entre em contato",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
"manage_card_details": "Gerenciar Detalhes do Cartão",
"manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"multi_language_surveys": "Pesquisas Multilíngues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diz oi!",
"scale": "escala",
"scale_description": "Recursos avançados pra escalar seu negócio.",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_apps_websites": "Apps e Sites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",
@@ -1230,6 +1221,7 @@
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
"copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"edit": {

View File

@@ -316,6 +316,7 @@
"remove": "Remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"response": "Resposta",
@@ -979,63 +980,53 @@
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"1000_monthly_responses": "1000 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2,000 Contactos",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
"7500_contacts": "7,500 Contactos",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todas as funcionalidades de inquérito",
"annually": "Anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos da Aplicação",
"contact_us": "Contacte-nos",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "Atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível",
"custom_miu_limit": "Limite MIU Personalizado",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contactos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gestor de Sucesso do Cliente",
"custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Inquéritos Incorporados no Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"email_follow_ups": "Acompanhamentos por Email",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo em Gratuito",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis",
"get_in_touch": "Entre em contacto",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Ligar Inquéritos (Partilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
"manage_card_details": "Gerir Detalhes do Cartão",
"manage_subscription": "Gerir Subscrição",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"multi_language_surveys": "Inquéritos Multilingues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diga Olá!",
"scale": "Escala",
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
"startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",
@@ -1230,6 +1221,7 @@
"copy_survey_description": "Copiar este questionário para outro ambiente",
"copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"edit": {

View File

@@ -316,6 +316,7 @@
"remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
@@ -979,63 +980,53 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"10000_monthly_responses": "10000 個每月回應",
"1500_monthly_responses": "1500 個每月回應",
"2000_monthly_identified_users": "2000 個每月識別使用者",
"30000_monthly_identified_users": "30000 個每月識別使用者",
"1000_monthly_responses": "1000 個每月回應",
"1_project": "1 個專案",
"2000_contacts": "2000 個聯絡人",
"3_projects": "3 個專案",
"5000_monthly_responses": "5000 個每月回應",
"5_projects": "5 個專案",
"7500_monthly_identified_users": "7500 個每月識別使用者",
"advanced_targeting": "進階目標設定",
"7500_contacts": "7500 個聯絡人",
"all_integrations": "所有整合",
"all_surveying_features": "所有調查功能",
"annually": "每年",
"api_webhooks": "API 和 Webhook",
"app_surveys": "應用程式問卷",
"contact_us": "聯絡我們",
"attribute_based_targeting": "基於屬性的定位",
"current": "目前",
"current_plan": "目前方案",
"current_tier_limit": "目前層級限制",
"custom_miu_limit": "自訂 MIU 上限",
"custom": "自訂 & 規模",
"custom_contacts_limit": "自訂聯絡人上限",
"custom_project_limit": "自訂專案上限",
"customer_success_manager": "客戶成功經理",
"custom_response_limit": "自訂回應上限",
"email_embedded_surveys": "電子郵件嵌入式問卷",
"email_support": "電子郵件支援",
"enterprise": "企業版",
"email_follow_ups": "電子郵件後續追蹤",
"enterprise_description": "頂級支援和自訂限制。",
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
"everything_in_free": "免費方案中的所有功能",
"everything_in_scale": "進階方案中的所有功能",
"everything_in_startup": "啟動方案中的所有功能",
"free": "免費",
"free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月",
"get_in_touch": "取得聯繫",
"hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)",
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
"manage_card_details": "管理卡片詳細資料",
"manage_subscription": "管理訂閱",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"multi_language_surveys": "多語言問卷",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"priority_support": "優先支援",
"remove_branding": "移除品牌",
"say_hi": "打個招呼!",
"scale": "進階版",
"scale_description": "用於擴展業務的進階功能。",
"startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色",
"technical_onboarding": "技術新手上路",
"unable_to_upgrade_plan": "無法升級方案",
"unlimited_apps_websites": "無限應用程式和網站",
"unlimited_miu": "無限 MIU",
"unlimited_projects": "無限專案",
"unlimited_responses": "無限回應",
@@ -1230,6 +1221,7 @@
"copy_survey_description": "將此問卷複製到另一個環境",
"copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"edit": {

View File

@@ -1,87 +1,87 @@
import { TFnType } from "@tolgee/react";
export const getCloudPricingData = (t: TFnType) => {
return {
plans: [
{
name: t("environments.settings.billing.free"),
id: "free",
featured: false,
description: t("environments.settings.billing.free_description"),
price: { monthly: "$0", yearly: "$0" },
mainFeatures: [
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.unlimited_team_members"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.1500_monthly_responses"),
t("environments.settings.billing.2000_monthly_identified_users"),
t("environments.settings.billing.website_surveys"),
t("environments.settings.billing.app_surveys"),
t("environments.settings.billing.unlimited_apps_websites"),
t("environments.settings.billing.link_surveys"),
t("environments.settings.billing.email_embedded_surveys"),
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
t("environments.settings.billing.api_webhooks"),
t("environments.settings.billing.all_integrations"),
t("environments.settings.billing.all_surveying_features"),
],
href: "https://app.formbricks.com/auth/signup?plan=free",
},
{
name: t("environments.settings.billing.startup"),
id: "startup",
featured: false,
description: t("environments.settings.billing.startup_description"),
price: { monthly: "$39", yearly: "$390 " },
mainFeatures: [
t("environments.settings.billing.everything_in_free"),
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_support"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_monthly_identified_users"),
],
href: "https://app.formbricks.com/auth/signup?plan=startup",
},
{
name: t("environments.settings.billing.scale"),
id: "scale",
featured: true,
description: t("environments.settings.billing.scale_description"),
price: { monthly: "$149", yearly: "$1,490" },
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.team_access_roles"),
t("environments.settings.billing.multi_language_surveys"),
t("environments.settings.billing.advanced_targeting"),
t("environments.settings.billing.priority_support"),
t("environments.settings.billing.5_projects"),
t("environments.settings.billing.10000_monthly_responses"),
t("environments.settings.billing.30000_monthly_identified_users"),
],
href: "https://app.formbricks.com/auth/signup?plan=scale",
},
{
name: t("environments.settings.billing.enterprise"),
id: "enterprise",
featured: false,
description: t("environments.settings.billing.enterprise_description"),
price: {
monthly: t("environments.settings.billing.say_hi"),
yearly: t("environments.settings.billing.say_hi"),
},
mainFeatures: [
t("environments.settings.billing.everything_in_scale"),
t("environments.settings.billing.custom_project_limit"),
t("environments.settings.billing.custom_miu_limit"),
t("environments.settings.billing.premium_support_with_slas"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.customer_success_manager"),
t("environments.settings.billing.technical_onboarding"),
],
href: "https://cal.com/johannes/enterprise-cloud",
},
export type TPricingPlan = {
id: string;
name: string;
featured: boolean;
CTA?: string;
description: string;
price: {
monthly: string;
yearly: string;
};
mainFeatures: string[];
href?: string;
};
export const getCloudPricingData = (t: TFnType): { plans: TPricingPlan[] } => {
const freePlan: TPricingPlan = {
id: "free",
name: t("environments.settings.billing.free"),
featured: false,
description: t("environments.settings.billing.free_description"),
price: { monthly: "$0", yearly: "$0" },
mainFeatures: [
t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.1000_monthly_responses"),
t("environments.settings.billing.2000_contacts"),
t("environments.settings.billing.1_project"),
t("environments.settings.billing.unlimited_team_members"),
t("environments.settings.billing.link_surveys"),
t("environments.settings.billing.website_surveys"),
t("environments.settings.billing.app_surveys"),
t("environments.settings.billing.ios_android_sdks"),
t("environments.settings.billing.email_embedded_surveys"),
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
t("environments.settings.billing.api_webhooks"),
t("environments.settings.billing.all_integrations"),
t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺",
],
};
const startupPlan: TPricingPlan = {
id: "startup",
name: t("environments.settings.billing.startup"),
featured: true,
CTA: t("common.start_free_trial"),
description: t("environments.settings.billing.startup_description"),
price: { monthly: "$49", yearly: "$490" },
mainFeatures: [
t("environments.settings.billing.everything_in_free"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_contacts"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.attribute_based_targeting"),
],
};
const customPlan: TPricingPlan = {
id: "enterprise",
name: t("environments.settings.billing.custom"),
featured: false,
CTA: t("common.request_pricing"),
description: t("environments.settings.billing.enterprise_description"),
price: {
monthly: t("environments.settings.billing.custom"),
yearly: t("environments.settings.billing.custom"),
},
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.custom_response_limit"),
t("environments.settings.billing.custom_contacts_limit"),
t("environments.settings.billing.custom_project_limit"),
t("environments.settings.billing.team_access_roles"),
t("environments.project.languages.multi_language_surveys"),
t("environments.settings.enterprise.saml_sso"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.premium_support_with_slas"),
],
href: "https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud",
};
return {
plans: [freePlan, startupPlan, customPlan],
};
};

View File

@@ -8,19 +8,10 @@ import { useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { TPricingPlan } from "../api/lib/constants";
interface PricingCardProps {
plan: {
id: string;
name: string;
featured: boolean;
price: {
monthly: string;
yearly: string;
};
mainFeatures: string[];
href: string;
};
plan: TPricingPlan;
planPeriod: TOrganizationBillingPeriod;
organization: TOrganization;
onUpgrade: () => Promise<void>;
@@ -28,7 +19,6 @@ interface PricingCardProps {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
SCALE: string;
ENTERPRISE: string;
};
}
@@ -72,18 +62,33 @@ export const PricingCard = ({
return null;
}
if (plan.id !== projectFeatureKeys.ENTERPRISE && plan.id !== projectFeatureKeys.FREE) {
if (plan.id === projectFeatureKeys.ENTERPRISE) {
return (
<Button
variant="outline"
loading={loading}
onClick={() => {
window.open(plan.href, "_blank", "noopener,noreferrer");
}}
className="flex justify-center bg-white">
{t(plan.CTA ?? "common.request_pricing")}
</Button>
);
}
if (plan.id === projectFeatureKeys.STARTUP) {
if (organization.billing.plan === projectFeatureKeys.FREE) {
return (
<Button
loading={loading}
variant="default"
onClick={async () => {
setLoading(true);
await onUpgrade();
setLoading(false);
}}
className="flex justify-center">
{t("common.start_free_trial")}
{t(plan.CTA ?? "common.start_free_trial")}
</Button>
);
}
@@ -100,15 +105,20 @@ export const PricingCard = ({
);
}
return <></>;
return null;
}, [
isCurrentPlan,
loading,
onUpgrade,
organization.billing.plan,
plan.CTA,
plan.featured,
plan.href,
plan.id,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP,
t,
]);
return (
@@ -147,7 +157,7 @@ export const PricingCard = ({
: plan.price.yearly
: t(plan.price.monthly)}
</p>
{plan.name !== "Enterprise" && (
{plan.id !== projectFeatureKeys.ENTERPRISE && (
<div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"}
@@ -171,16 +181,9 @@ export const PricingCard = ({
{t("environments.settings.billing.manage_subscription")}
</Button>
)}
{organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && (
<Button loading={loading} onClick={() => onUpgrade()} className="flex justify-center">
{t("environments.settings.billing.contact_us")}
</Button>
)}
</div>
<div className="mt-8 flow-root sm:mt-10">
<ul
role="list"
className={cn(
plan.featured
? "divide-slate-900/5 border-slate-900/5 text-slate-600"
@@ -193,7 +196,6 @@ export const PricingCard = ({
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
aria-hidden="true"
/>
{t(mainFeature)}
</li>
))}

View File

@@ -21,15 +21,12 @@ interface PricingTableProps {
responseCount: number;
projectCount: number;
stripePriceLookupKeys: {
STARTUP_MONTHLY: string;
STARTUP_YEARLY: string;
SCALE_MONTHLY: string;
SCALE_YEARLY: string;
STARTUP_MAY25_MONTHLY: string;
STARTUP_MAY25_YEARLY: string;
};
projectFeatureKeys: {
FREE: string;
STARTUP: string;
SCALE: string;
ENTERPRISE: string;
};
hasBillingRights: boolean;
@@ -102,35 +99,32 @@ export const PricingTable = ({
throw new Error(t("common.something_went_wrong_please_try_again"));
}
} catch (err) {
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
if (err instanceof Error) {
toast.error(err.message);
} else {
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
}
}
};
const onUpgrade = async (planId: string) => {
if (planId === "scale") {
await upgradePlan(
planPeriod === "monthly" ? stripePriceLookupKeys.SCALE_MONTHLY : stripePriceLookupKeys.SCALE_YEARLY
);
return;
}
if (planId === "startup") {
await upgradePlan(
planPeriod === "monthly"
? stripePriceLookupKeys.STARTUP_MONTHLY
: stripePriceLookupKeys.STARTUP_YEARLY
? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
: stripePriceLookupKeys.STARTUP_MAY25_YEARLY
);
return;
}
if (planId === "enterprise") {
window.location.href = "https://cal.com/johannes/license";
if (planId === "custom") {
window.location.href =
"https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud";
return;
}
if (planId === "free") {
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
return;
}
};
@@ -233,7 +227,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-12",
"relative mx-8 flex flex-col gap-4 pb-6",
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
@@ -282,7 +276,7 @@ export const PricingTable = ({
</span>
</button>
</div>
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
<div
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
aria-hidden="true"

View File

@@ -1,13 +1,14 @@
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { CopySurveyForm } from "./copy-survey-form";
// Mock dependencies
vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn().mockResolvedValue({}),
copySurveyToOtherEnvironmentAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
@@ -19,21 +20,40 @@ vi.mock("react-hot-toast", () => ({
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
t: (key: string, params?: any) => {
if (key === "environments.surveys.copy_survey_partially_success") {
return `Partially successful: ${params?.success} success, ${params?.error} error`;
}
return key;
},
}),
}));
// Mock the Checkbox component to properly handle form changes
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((result) => {
if (result?.serverError) return result.serverError;
if (result?.validationErrors) return "Validation error";
return "Unknown error";
}),
}));
// Mock the form components to make them testable
vi.mock("@/modules/ui/components/form", () => ({
FormProvider: ({ children }: any) => <div data-testid="form-provider">{children}</div>,
FormField: ({ children, render }: any) => (
<div data-testid="form-field">{render({ field: { value: [], onChange: vi.fn() } })}</div>
),
FormItem: ({ children }: any) => <div data-testid="form-item">{children}</div>,
FormControl: ({ children }: any) => <div data-testid="form-control">{children}</div>,
}));
vi.mock("@/modules/ui/components/checkbox", () => ({
Checkbox: ({ id, onCheckedChange, ...props }: any) => (
<input
type="checkbox"
id={id}
data-testid={id}
name={props.name}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
onChange={(e) => {
// Call onCheckedChange with the checked state
onCheckedChange && onCheckedChange(e.target.checked);
}}
{...props}
@@ -54,10 +74,47 @@ vi.mock("@/modules/ui/components/button", () => ({
),
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children, htmlFor }: any) => <label htmlFor={htmlFor}>{children}</label>,
}));
// Create a mock submit handler
let mockSubmitHandler: any = null;
// Mock react-hook-form
vi.mock("react-hook-form", () => ({
useForm: () => ({
control: {},
handleSubmit: (fn: any) => {
mockSubmitHandler = fn;
return (e: any) => {
e.preventDefault();
// Simulate form data with selected environments
const mockFormData = {
projects: [
{
project: "project-1",
environments: ["env-2"], // Only env-2 selected
},
{
project: "project-2",
environments: ["env-3"], // Only env-3 selected
},
],
};
return fn(mockFormData);
};
},
}),
useFieldArray: () => ({
fields: [{ project: "project-1" }, { project: "project-2" }],
}),
}));
// Mock data
const mockSurvey = {
id: "survey-1",
name: "mockSurvey",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
@@ -90,11 +147,16 @@ const mockProjects = [
describe("CopySurveyForm", () => {
const mockSetOpen = vi.fn();
const mockOnCancel = vi.fn();
const user = userEvent.setup();
const mockOnSurveysCopied = vi.fn();
// Get references to the mocked functions
const mockCopySurveyAction = vi.mocked(copySurveyToOtherEnvironmentAction);
const mockToastSuccess = vi.mocked(toast.success);
const mockToastError = vi.mocked(toast.error);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(copySurveyToOtherEnvironmentAction).mockResolvedValue({ data: { id: "new-survey-id" } });
mockSubmitHandler = null;
});
afterEach(() => {
@@ -111,22 +173,14 @@ describe("CopySurveyForm", () => {
/>
);
// Check if project names are rendered
expect(screen.getByText("Project 1")).toBeInTheDocument();
expect(screen.getByText("Project 2")).toBeInTheDocument();
// Check if environment types are rendered
expect(screen.getAllByText("development").length).toBe(2);
expect(screen.getAllByText("development").length).toBe(1);
expect(screen.getAllByText("production").length).toBe(2);
// Check if checkboxes are rendered for each environment
expect(screen.getByTestId("env-1")).toBeInTheDocument();
expect(screen.getByTestId("env-2")).toBeInTheDocument();
expect(screen.getByTestId("env-3")).toBeInTheDocument();
expect(screen.getByTestId("env-4")).toBeInTheDocument();
});
test("calls onCancel when cancel button is clicked", async () => {
const user = userEvent.setup();
render(
<CopySurveyForm
defaultProjects={mockProjects}
@@ -142,45 +196,252 @@ describe("CopySurveyForm", () => {
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
test("toggles environment selection when checkbox is clicked", async () => {
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
describe("onSubmit function", () => {
test("should handle successful operations", async () => {
mockCopySurveyAction.mockResolvedValue({
data: { id: "new-survey-1", environmentId: "env-2" },
});
// Select multiple environments
await user.click(screen.getByTestId("env-2"));
await user.click(screen.getByTestId("env-3"));
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
// Submit the form
await user.click(screen.getByTestId("button-submit"));
// Call the submit handler directly
const mockFormData = {
projects: [
{
project: "project-1",
environments: ["env-2"],
},
{
project: "project-2",
environments: ["env-3"],
},
],
};
// Just verify the form can be submitted (integration testing is complex with mocked components)
expect(screen.getByTestId("button-submit")).toBeInTheDocument();
});
await mockSubmitHandler(mockFormData);
test("submits form with selected environments", async () => {
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
await waitFor(() => {
expect(mockCopySurveyAction).toHaveBeenCalledTimes(2);
expect(mockCopySurveyAction).toHaveBeenCalledWith({
environmentId: "env-1",
surveyId: "survey-1",
targetEnvironmentId: "env-2",
});
expect(mockCopySurveyAction).toHaveBeenCalledWith({
environmentId: "env-1",
surveyId: "survey-1",
targetEnvironmentId: "env-3",
});
expect(mockToastSuccess).toHaveBeenCalledWith("environments.surveys.copy_survey_success");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
// Select environments
await user.click(screen.getByTestId("env-2"));
await user.click(screen.getByTestId("env-4"));
test("should handle partial success with mixed results", async () => {
mockCopySurveyAction
.mockResolvedValueOnce({ data: { id: "new-survey-1", environmentId: "env-2" } })
.mockResolvedValueOnce({ serverError: "Failed to copy" });
// Submit the form
await user.click(screen.getByTestId("button-submit"));
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
// Just verify basic form functionality (complex integration testing with mocked components is challenging)
expect(screen.getByTestId("button-submit")).toBeInTheDocument();
const mockFormData = {
projects: [
{
project: "project-1",
environments: ["env-2"],
},
{
project: "project-2",
environments: ["env-3"],
},
],
};
await mockSubmitHandler(mockFormData);
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith(
"Partially successful: 1 success, 1 error",
expect.objectContaining({
icon: expect.anything(),
})
);
expect(mockToastError).toHaveBeenCalledWith(
"[Project 2] - [development] - Failed to copy",
expect.objectContaining({
duration: 2000,
})
);
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("should handle all failed operations", async () => {
mockCopySurveyAction
.mockResolvedValueOnce({ serverError: "Server error 1" })
.mockResolvedValueOnce({ validationErrors: { surveyId: { _errors: ["Invalid survey ID"] } } });
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
const mockFormData = {
projects: [
{
project: "project-1",
environments: ["env-2"],
},
{
project: "project-2",
environments: ["env-3"],
},
],
};
await mockSubmitHandler(mockFormData);
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith(
"[Project 1] - [production] - Server error 1",
expect.objectContaining({
duration: 2000,
})
);
expect(mockToastError).toHaveBeenCalledWith(
"[Project 2] - [development] - Validation error",
expect.objectContaining({
duration: 4000,
})
);
expect(mockToastSuccess).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("should handle exceptions during form submission", async () => {
mockCopySurveyAction.mockRejectedValue(new Error("Network error"));
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
const mockFormData = {
projects: [
{
project: "project-1",
environments: ["env-2"],
},
],
};
await mockSubmitHandler(mockFormData);
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith("environments.surveys.copy_survey_error");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("should handle staggered error toast durations", async () => {
mockCopySurveyAction
.mockResolvedValueOnce({ serverError: "Error 1" })
.mockResolvedValueOnce({ serverError: "Error 2" })
.mockResolvedValueOnce({ serverError: "Error 3" });
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
const mockFormData = {
projects: [
{
project: "project-1",
environments: ["env-2"],
},
{
project: "project-2",
environments: ["env-3", "env-4"],
},
],
};
await mockSubmitHandler(mockFormData);
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith(
"[Project 1] - [production] - Error 1",
expect.objectContaining({ duration: 2000 })
);
expect(mockToastError).toHaveBeenCalledWith(
"[Project 2] - [development] - Error 2",
expect.objectContaining({ duration: 4000 })
);
expect(mockToastError).toHaveBeenCalledWith(
"[Project 2] - [production] - Error 3",
expect.objectContaining({ duration: 6000 })
);
});
});
test("should not call onSurveysCopied when it's not provided", async () => {
mockCopySurveyAction.mockResolvedValue({
data: { id: "new-survey-1", environmentId: "env-1" },
});
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
// onSurveysCopied not provided
/>
);
const mockFormData = {
projects: [
{
project: "project-1",
environments: ["env-2"],
},
],
};
await mockSubmitHandler(mockFormData);
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Should not throw an error even when onSurveysCopied is not provided
});
});
});
});

View File

@@ -10,22 +10,100 @@ import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/com
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import toast from "react-hot-toast";
interface ICopySurveyFormProps {
defaultProjects: TUserProject[];
survey: TSurvey;
onCancel: () => void;
setOpen: (value: boolean) => void;
interface CopySurveyFormProps {
readonly defaultProjects: TUserProject[];
readonly survey: TSurvey;
readonly onCancel: () => void;
readonly setOpen: (value: boolean) => void;
}
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: ICopySurveyFormProps) => {
interface EnvironmentCheckboxProps {
readonly environmentId: string;
readonly environmentType: string;
readonly fieldValue: string[];
readonly onChange: (value: string[]) => void;
}
function EnvironmentCheckbox({
environmentId,
environmentType,
fieldValue,
onChange,
}: EnvironmentCheckboxProps) {
const handleCheckedChange = () => {
if (fieldValue.includes(environmentId)) {
onChange(fieldValue.filter((id) => id !== environmentId));
} else {
onChange([...fieldValue, environmentId]);
}
};
return (
<FormItem>
<div className="flex items-center">
<FormControl>
<div className="flex items-center">
<Checkbox
type="button"
checked={fieldValue.includes(environmentId)}
onCheckedChange={handleCheckedChange}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
id={environmentId}
/>
<Label htmlFor={environmentId}>
<p className="text-sm font-medium capitalize text-slate-900">{environmentType}</p>
</Label>
</div>
</FormControl>
</div>
</FormItem>
);
}
interface EnvironmentCheckboxGroupProps {
readonly project: TUserProject;
readonly form: ReturnType<typeof useForm<TSurveyCopyFormData>>;
readonly projectIndex: number;
}
function EnvironmentCheckboxGroup({ project, form, projectIndex }: EnvironmentCheckboxGroupProps) {
return (
<div className="flex flex-col gap-4">
{project.environments.map((environment) => (
<FormField
key={environment.id}
control={form.control}
name={`projects.${projectIndex}.environments`}
render={({ field }) => (
<EnvironmentCheckbox
environmentId={environment.id}
environmentType={environment.type}
fieldValue={field.value}
onChange={field.onChange}
/>
)}
/>
))}
</div>
);
}
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: CopySurveyFormProps) => {
const { t } = useTranslate();
const filteredProjects = defaultProjects.map((project) => ({
...project,
environments: project.environments.filter((env) => env.id !== survey.environmentId),
}));
const form = useForm<TSurveyCopyFormData>({
resolver: zodResolver(ZSurveyCopyFormValidation),
defaultValues: {
projects: defaultProjects.map((project) => ({
projects: filteredProjects.map((project) => ({
project: project.id,
environments: [],
})),
@@ -37,32 +115,79 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
control: form.control,
});
const onSubmit = async (data: TSurveyCopyFormData) => {
async function onSubmit(data: TSurveyCopyFormData) {
const filteredData = data.projects.filter((project) => project.environments.length > 0);
try {
filteredData.forEach(async (project) => {
project.environments.forEach(async (environment) => {
const result = await copySurveyToOtherEnvironmentAction({
environmentId: survey.environmentId,
surveyId: survey.id,
targetEnvironmentId: environment,
});
const copyOperationsWithMetadata = filteredData.flatMap((projectData) => {
const project = filteredProjects.find((p) => p.id === projectData.project);
return projectData.environments.map((environmentId) => {
const environment =
project?.environments[0]?.id === environmentId
? project?.environments[0]
: project?.environments[1];
if (result?.data) {
toast.success(t("environments.surveys.copy_survey_success"));
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
return {
operation: copySurveyToOtherEnvironmentAction({
environmentId: survey.environmentId,
surveyId: survey.id,
targetEnvironmentId: environmentId,
}),
projectName: project?.name ?? "Unknown Project",
environmentType: environment?.type ?? "unknown",
environmentId,
};
});
});
const results = await Promise.all(copyOperationsWithMetadata.map((item) => item.operation));
let successCount = 0;
let errorCount = 0;
const errorsIndexes: number[] = [];
results.forEach((result, index) => {
if (result?.data) {
successCount++;
} else {
errorsIndexes.push(index);
errorCount++;
}
});
if (successCount > 0) {
if (errorCount === 0) {
toast.success(t("environments.surveys.copy_survey_success"));
} else {
toast.error(
t("environments.surveys.copy_survey_partially_success", {
success: successCount,
error: errorCount,
}),
{
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
}
);
}
}
if (errorsIndexes.length > 0) {
errorsIndexes.forEach((index, idx) => {
const { projectName, environmentType } = copyOperationsWithMetadata[index];
const result = results[index];
const errorMessage = getFormattedErrorMessage(result);
toast.error(`[${projectName}] - [${environmentType}] - ${errorMessage}`, {
duration: 2000 + 2000 * idx,
});
});
}
} catch (error) {
toast.error(t("environments.surveys.copy_survey_error"));
} finally {
setOpen(false);
}
};
}
return (
<FormProvider {...form}>
@@ -71,58 +196,16 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
className="relative flex h-full w-full flex-col gap-8 overflow-y-auto bg-white p-4">
<div className="space-y-8 pb-12">
{formFields.fields.map((field, projectIndex) => {
const project = defaultProjects.find((project) => project.id === field.project);
const project = filteredProjects.find((project) => project.id === field.project);
if (!project) return null;
return (
<div key={project?.id}>
<div key={project.id}>
<div className="flex flex-col gap-4">
<div className="w-fit">
<p className="text-base font-semibold text-slate-900">{project?.name}</p>
</div>
<div className="flex flex-col gap-4">
{project?.environments.map((environment) => {
return (
<FormField
key={environment.id}
control={form.control}
name={`projects.${projectIndex}.environments`}
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center">
<FormControl>
<>
<Checkbox
{...field}
type="button"
onCheckedChange={() => {
if (field.value.includes(environment.id)) {
field.onChange(
field.value.filter((id: string) => id !== environment.id)
);
} else {
field.onChange([...field.value, environment.id]);
}
}}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
id={environment.id}
/>
<Label htmlFor={environment.id}>
<p className="text-sm font-medium capitalize text-slate-900">
{environment.type}
</p>
</Label>
</>
</FormControl>
</div>
</FormItem>
);
}}
/>
);
})}
<p className="text-base font-semibold text-slate-900">{project.name}</p>
</div>
<EnvironmentCheckboxGroup project={project} form={form} projectIndex={projectIndex} />
</div>
</div>
);
@@ -133,7 +216,6 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
<Button type="button" onClick={onCancel} variant="ghost">
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
</div>
</div>

View File

@@ -17,9 +17,9 @@ interface SurveyCardProps {
environmentId: string;
isReadOnly: boolean;
publicDomain: string;
duplicateSurvey: (survey: TSurvey) => void;
deleteSurvey: (surveyId: string) => void;
locale: TUserLocale;
onSurveysCopied?: () => void;
}
export const SurveyCard = ({
survey,
@@ -27,8 +27,8 @@ export const SurveyCard = ({
isReadOnly,
publicDomain,
deleteSurvey,
duplicateSurvey,
locale,
onSurveysCopied,
}: SurveyCardProps) => {
const { t } = useTranslate();
const surveyStatusLabel = (() => {
@@ -106,8 +106,8 @@ export const SurveyCard = ({
disabled={isDraftAndReadOnly}
refreshSingleUseId={refreshSingleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
duplicateSurvey={duplicateSurvey}
deleteSurvey={deleteSurvey}
onSurveysCopied={onSurveysCopied}
/>
</button>
</>

View File

@@ -86,7 +86,6 @@ describe("SurveyDropDownMenu", () => {
survey={{ ...fakeSurvey, status: "completed" }}
publicDomain="http://survey.test"
refreshSingleUseId={mockRefresh}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={mockDeleteSurvey}
/>
);
@@ -118,7 +117,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={vi.fn()}
disabled={false}
isSurveyCreationDeletionDisabled={false}
@@ -158,7 +156,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={vi.fn()}
/>
);
@@ -181,7 +178,6 @@ describe("SurveyDropDownMenu", () => {
survey={{ ...fakeSurvey, responseCount: 0 }}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={vi.fn()}
/>
);
@@ -200,14 +196,12 @@ describe("SurveyDropDownMenu", () => {
});
test("<DropdownMenuItem> renders and triggers actions correctly", async () => {
const mockDuplicateSurvey = vi.fn();
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={vi.fn()}
/>
);
@@ -220,21 +214,15 @@ describe("SurveyDropDownMenu", () => {
const duplicateButton = screen.getByText("common.duplicate");
expect(duplicateButton).toBeInTheDocument();
await userEvent.click(duplicateButton);
await waitFor(() => {
expect(mockDuplicateSurvey).toHaveBeenCalled();
});
});
test("<EditPublicSurveyAlertDialog> displays and handles actions correctly", async () => {
const mockDuplicateSurvey = vi.fn();
render(
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, responseCount: 5 }}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={vi.fn()}
/>
);
@@ -260,10 +248,6 @@ describe("SurveyDropDownMenu", () => {
const duplicateButton = screen.getByRole("button", { name: "common.duplicate" });
expect(duplicateButton).toBeInTheDocument();
await userEvent.click(duplicateButton);
await waitFor(() => {
expect(mockDuplicateSurvey).toHaveBeenCalled();
});
});
describe("handleDeleteSurvey", () => {
@@ -281,7 +265,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
@@ -317,7 +300,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
@@ -354,7 +336,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
@@ -391,7 +372,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
@@ -429,7 +409,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
@@ -484,7 +463,6 @@ describe("SurveyDropDownMenu", () => {
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);

View File

@@ -41,8 +41,8 @@ interface SurveyDropDownMenuProps {
refreshSingleUseId: () => Promise<string | undefined>;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
duplicateSurvey: (survey: TSurvey) => void;
deleteSurvey: (surveyId: string) => void;
onSurveysCopied?: () => void;
}
export const SurveyDropDownMenu = ({
@@ -53,7 +53,7 @@ export const SurveyDropDownMenu = ({
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
duplicateSurvey,
onSurveysCopied,
}: SurveyDropDownMenuProps) => {
const { t } = useTranslate();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -102,13 +102,14 @@ export const SurveyDropDownMenu = ({
surveyId,
targetEnvironmentId: environmentId,
});
router.refresh();
if (duplicatedSurveyResponse?.data) {
const transformedDuplicatedSurvey = await getSurveyAction({
surveyId: duplicatedSurveyResponse.data.id,
});
if (transformedDuplicatedSurvey?.data) duplicateSurvey(transformedDuplicatedSurvey.data);
if (transformedDuplicatedSurvey?.data) {
onSurveysCopied?.();
}
toast.success(t("environments.surveys.survey_duplicated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);

View File

@@ -341,27 +341,6 @@ describe("SurveysList", () => {
});
});
test("handleDuplicateSurvey adds the duplicated survey to the beginning of the list", async () => {
const initialSurvey = { ...surveyMock, id: "s1", name: "Original Survey" };
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [initialSurvey] });
const user = userEvent.setup();
render(<SurveysList {...defaultProps} />);
await waitFor(() => expect(screen.getByText("Original Survey")).toBeInTheDocument());
const duplicateButtonS1 = screen.getByTestId("duplicate-s1");
// The mock SurveyCard calls duplicateSurvey(survey) with the original survey object.
await user.click(duplicateButtonS1);
await waitFor(() => {
const surveyCards = screen.getAllByTestId(/survey-card-/);
expect(surveyCards).toHaveLength(2);
// Both cards will show "Original Survey" as the object is prepended.
expect(surveyCards[0]).toHaveTextContent("Original Survey");
expect(surveyCards[1]).toHaveTextContent("Original Survey");
});
});
test("applies useAutoAnimate ref to the survey list container", async () => {
const surveysData = [{ ...surveyMock, id: "s1" }];
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });

View File

@@ -46,6 +46,7 @@ export const SurveysList = ({
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(true);
const [hasMore, setHasMore] = useState<boolean>(true);
const [refreshTrigger, setRefreshTrigger] = useState(false);
const { t } = useTranslate();
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
@@ -98,7 +99,7 @@ export const SurveysList = ({
};
fetchInitialSurveys();
}
}, [environmentId, surveysLimit, filters, isFilterInitialized]);
}, [environmentId, surveysLimit, filters, isFilterInitialized, refreshTrigger]);
const fetchNextPage = useCallback(async () => {
setIsFetching(true);
@@ -126,10 +127,9 @@ export const SurveysList = ({
if (newSurveys.length === 0) setIsFetching(true);
};
const handleDuplicateSurvey = async (survey: TSurvey) => {
const newSurveys = [survey, ...surveys];
setSurveys(newSurveys);
};
const triggerRefresh = useCallback(() => {
setRefreshTrigger((prev) => !prev);
}, []);
return (
<div className="space-y-6">
@@ -158,9 +158,9 @@ export const SurveysList = ({
environmentId={environmentId}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
duplicateSurvey={handleDuplicateSurvey}
deleteSurvey={handleDeleteSurvey}
locale={locale}
onSurveysCopied={triggerRefresh}
/>
);
})}

View File

@@ -20,7 +20,7 @@ const nextConfig = {
cacheMaxMemorySize: 0, // disable default in-memory caching
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: false,
productionBrowserSourceMaps: true,
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
outputFileTracingIncludes: {
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
@@ -425,6 +425,7 @@ const sentryOptions = {
org: "formbricks",
project: "formbricks-cloud",
environment: process.env.SENTRY_ENVIRONMENT,
// Only print logs for uploading source maps in CI
silent: true,
@@ -434,11 +435,23 @@ const sentryOptions = {
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Disable automatic release management
automaticVercelMonitors: false,
autoUploadSourceMaps: false,
hideSourceMaps: false,
// Don't automatically create releases - we handle this in GitHub Actions
release: {
create: false,
deploy: false,
setCommits: false,
},
};
const exportConfig =
process.env.SENTRY_DSN && process.env.NODE_ENV === "production"
? withSentryConfig(nextConfig, sentryOptions)
: nextConfig;
(process.env.SENTRY_DSN && process.env.NODE_ENV === "production")
? withSentryConfig(nextConfig, sentryOptions) :
nextConfig;
export default exportConfig;

View File

@@ -2,7 +2,7 @@
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import { SENTRY_DSN } from "@/lib/constants";
import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
@@ -11,6 +11,8 @@ if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
release: SENTRY_RELEASE,
environment: SENTRY_ENVIRONMENT,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,

View File

@@ -1,7 +1,7 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import { SENTRY_DSN } from "@/lib/constants";
import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
@@ -10,6 +10,8 @@ if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
release: SENTRY_RELEASE,
environment: SENTRY_ENVIRONMENT,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,

View File

@@ -82,6 +82,8 @@ x-environment: &environment
# SENTRY_DSN:
# It's used for authentication when uploading source maps to Sentry, to make errors more readable.
# SENTRY_AUTH_TOKEN:
# The SENTRY_ENVIRONMENT is used to identify the environment in Sentry.
# SENTRY_ENVIRONMENT:
################################################### OPTIONAL (STORAGE) ###################################################

View File

@@ -54,7 +54,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
@@ -68,6 +68,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
| DEFAULT_TEAM_ID | Default team ID for new users. | optional | |
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional | |
| SENTRY_ENVIRONMENT | Set this to identify the environment in Sentry | optional | |
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional | |
| SESSION_MAX_AGE | Configure the maximum age for the session in seconds. | optional | 86400 (24 hours) |
| USER_MANAGEMENT_MINIMUM_ROLE | Set this to control which roles can access user management features. Accepted values: "owner", "manager", "disabled" | optional | manager |

View File

@@ -146,6 +146,7 @@
"SAML_DATABASE_URL",
"SESSION_MAX_AGE",
"SENTRY_DSN",
"SENTRY_ENVIRONMENT",
"SLACK_CLIENT_ID",
"SLACK_CLIENT_SECRET",
"SMTP_HOST",