From 2364cff54d50d5029a638c0646e8ec1cf6c57dcd Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 5 Feb 2026 12:01:14 +0100 Subject: [PATCH] feat: custom feedback dialog for feature opt-in (#27578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add delayed formbricks tracking for feature opt-in Adds delayed Formbricks survey tracking for feature opt-in. When a user opts into a feature, this allows triggering a Formbricks action after a configurable delay (e.g., 24 hours later) to collect feedback once they've had time to use the feature. Key changes: - Added `formbricks` config option to `OptInFeatureConfig` interface with `actionName` and `delayMs` properties - Created `useFormbricksOptInTracking` hook that handles the delayed tracking logic - Added `isFeatureTracked` / `setFeatureTracked` storage helpers to prevent duplicate tracking - Integrated the tracking hook into `useFeatureOptInBanner` Co-Authored-By: Claude Opus 4.5 * upgrade formbricks * feat: replace formbricks popup with custom feedback dialog Instead of using Formbricks' built-in popup, we now show a custom Cal.com-styled feedback dialog that submits responses directly to Formbricks API via tRPC mutation. - Add FeedbackDialog component with emoji rating selector - Add feedback tRPC router for server-side Formbricks submission - Update useFormbricksOptInTracking to return dialog state - Add survey config fields (surveyId, questions) to config Co-Authored-By: Claude Opus 4.5 * feat: position feedback dialog at bottom-right corner - Use base-ui Dialog primitives for custom positioning - Position dialog at bottom-right to avoid Intercom overlap - Use z-index 10000 (below Intercom's high z-index) - Keep blocking backdrop for modal behavior - Use i18n keys for title/description - Add survey IDs for bookings-v3 feedback Co-Authored-By: Claude Opus 4.5 * feat: add i18n keys for feedback dialog title/description Allow each feature to specify custom i18n keys for the feedback dialog title and description via the formbricks config. - Add titleKey/descriptionKey to formbricks config interface - Pass i18n keys through feedbackDialogProps - Add bookings_v3_feedback_title/description translation keys Co-Authored-By: Claude Opus 4.5 * refactor: move FeedbackDialog into FeatureOptInBannerWrapper Better encapsulation - consumers of the feature opt-in banner no longer need to handle the feedback dialog separately. Co-Authored-By: Claude Opus 4.5 * feat: add 5 second delay before showing feedback dialog Ensures the page has time to finish loading before showing the feedback dialog, avoiding showing it while skeletons are still visible. Co-Authored-By: Claude Opus 4.5 * refactor: simplify feedback dialog UI - Remove redundant question labels - Add "(optional)" to comment placeholder Co-Authored-By: Claude Opus 4.5 * fix: remove emoji button borders and add footer gap - Remove borders from rating emoji buttons - Add proper gap between textarea and footer (pb-4) Co-Authored-By: Claude Opus 4.5 * fix: delayMs is opt-in waiting period, not setTimeout delay delayMs represents the minimum time that must pass since opt-in before showing the feedback form (e.g., 3 days). If not enough time has passed, we skip showing the form entirely instead of setting a long setTimeout. Co-Authored-By: Claude Opus 4.5 * feat: custom feedback dialog for feature opt-in - Replace Formbricks popup with Cal.com-styled dialog - Add configurable delay (waitAfterDays) before showing feedback - Position dialog at bottom-right, non-blocking - Add localStorage tracking to prevent duplicate feedback - Add device targeting (showOn: desktop/mobile/all) - Create tRPC endpoint for Formbricks API submission - Use proper logger for error handling Co-Authored-By: Claude Opus 4.5 * refactor: rename tracking terminology to feedback - Rename useFormbricksOptInTracking → useOptInFeedback - Rename FormbricksOptInTrackingResult → OptInFeedbackState - Rename formbricksTracking property → feedback - Rename FormbricksTrackingState → FeedbackState We no longer "track" events to Formbricks. Instead, we show our custom feedback dialog when conditions are met. Co-Authored-By: Claude Opus 4.5 * fix: set waitAfterDays to 3 for production feedback delay Co-Authored-By: unknown <> * fix: update formbricks JS SDK usage for v3.0.0 The @formbricks/js SDK v3.0.0 changed its API: - setup() no longer accepts debug, userId, or attributes - Use setUserId() and setAttributes() after setup instead - track() now expects { hiddenFields: ... } or undefined Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../hooks/useFeatureOptInBanner.ts | 7 + .../feature-opt-in/hooks/useOptInFeedback.ts | 100 ++++++++++ .../lib/feature-opt-in-storage.ts | 11 ++ .../modules/formbricks/hooks/useFormbricks.ts | 15 +- .../formbricks/lib/trackFormbricksAction.ts | 9 +- apps/web/pages/api/trpc/feedback/[trpc].ts | 4 + apps/web/public/static/locales/en/common.json | 11 ++ .../components/FeatureOptInBannerWrapper.tsx | 29 ++- packages/features/feature-opt-in/config.ts | 35 ++++ .../feedback/components/FeedbackDialog.tsx | 186 ++++++++++++++++++ packages/lib/formbricks.ts | 4 +- packages/lib/package.json | 4 +- packages/trpc/package.json | 1 + packages/trpc/react/shared.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 7 +- .../server/routers/viewer/feedback/_router.ts | 51 +++++ yarn.lock | 21 +- 17 files changed, 467 insertions(+), 29 deletions(-) create mode 100644 apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts create mode 100644 apps/web/pages/api/trpc/feedback/[trpc].ts create mode 100644 packages/features/feedback/components/FeedbackDialog.tsx create mode 100644 packages/trpc/server/routers/viewer/feedback/_router.ts diff --git a/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts index efbf962c18..e57a7a7686 100644 --- a/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts +++ b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts @@ -11,6 +11,8 @@ import { setFeatureDismissed, setFeatureOptedIn, } from "../lib/feature-opt-in-storage"; +import type { OptInFeedbackState } from "./useOptInFeedback"; +import { useOptInFeedback } from "./useOptInFeedback"; type UserRoleContext = { isOrgAdmin: boolean; @@ -48,6 +50,7 @@ type UseFeatureOptInBannerResult = { dismiss: () => void; markOptedIn: () => void; mutations: FeatureOptInMutations; + feedback: OptInFeedbackState; trackFeatureEnabled: (data: FeatureOptInTrackingData) => void; }; @@ -61,6 +64,9 @@ function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult { const [isOptedIn, setIsOptedIn] = useState(() => isFeatureOptedIn(featureId)); const featureConfig = useMemo(() => getOptInFeatureConfig(featureId) ?? null, [featureId]); + + const feedback = useOptInFeedback(featureId, featureConfig); + const utils = trpc.useUtils(); const eligibilityQuery = trpc.viewer.featureOptIn.checkFeatureOptInEligibility.useQuery( @@ -187,6 +193,7 @@ function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult { dismiss, markOptedIn, mutations, + feedback, trackFeatureEnabled, }; } diff --git a/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts b/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts new file mode 100644 index 0000000000..a2a60f6098 --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts @@ -0,0 +1,100 @@ +"use client"; + +import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + getFeatureOptInTimestamp, + isFeatureFeedbackShown, + setFeatureFeedbackShown, +} from "../lib/feature-opt-in-storage"; + +/** Delay before showing the feedback dialog to let the page finish loading */ +const PAGE_LOAD_DELAY_MS = 5000; + +export interface OptInFeedbackState { + /** Whether the feedback dialog should be shown */ + showFeedbackDialog: boolean; + /** Props to pass to the FeedbackDialog component */ + feedbackDialogProps: { + surveyId: string; + ratingQuestionId: string; + commentQuestionId: string; + titleKey?: string; + descriptionKey?: string; + showOn?: "all" | "desktop" | "mobile"; + } | null; + /** Call this when the dialog is closed (either submitted or skipped) */ + onFeedbackComplete: () => void; +} + +/** + * Hook to manage feedback dialog display after a user opts into a feature. + * Shows a custom feedback dialog after a configurable delay (waitAfterDays). + */ +function useOptInFeedback( + featureId: string, + featureConfig: OptInFeatureConfig | null +): OptInFeedbackState { + const [showFeedbackDialog, setShowFeedbackDialog] = useState(false); + const hasTriggeredRef = useRef(false); + + const onFeedbackComplete = useCallback(() => { + setShowFeedbackDialog(false); + setFeatureFeedbackShown(featureId); + }, [featureId]); + + useEffect(() => { + if (!featureConfig?.formbricks) return; + + // Don't trigger if already triggered this session + if (hasTriggeredRef.current) return; + + // Don't show if feedback was already shown (persisted in localStorage) + const alreadyShown = isFeatureFeedbackShown(featureId); + if (alreadyShown) return; + + // Don't trigger if user hasn't opted in yet + const optInTimestamp = getFeatureOptInTimestamp(featureId); + if (!optInTimestamp) return; + + // Don't trigger if survey is not configured + const { surveyId, questions } = featureConfig.formbricks; + if (!surveyId || !questions?.ratingQuestionId || !questions?.commentQuestionId) return; + + const { waitAfterDays } = featureConfig.formbricks; + const waitAfterMs = waitAfterDays * 24 * 60 * 60 * 1000; + const timeSinceOptIn = Date.now() - optInTimestamp; + + // Don't show feedback if not enough time has passed since opt-in + // (e.g., wait 3 days after opt-in before asking for feedback) + if (timeSinceOptIn < waitAfterMs) return; + + const triggerFeedback = (): void => { + hasTriggeredRef.current = true; + setShowFeedbackDialog(true); + }; + + // Show after page load delay to let the page finish loading + const timer = setTimeout(triggerFeedback, PAGE_LOAD_DELAY_MS); + return () => clearTimeout(timer); + }, [featureId, featureConfig]); + + const feedbackDialogProps = featureConfig?.formbricks?.surveyId + ? { + surveyId: featureConfig.formbricks.surveyId, + ratingQuestionId: featureConfig.formbricks.questions.ratingQuestionId, + commentQuestionId: featureConfig.formbricks.questions.commentQuestionId, + titleKey: featureConfig.formbricks.titleKey, + descriptionKey: featureConfig.formbricks.descriptionKey, + showOn: featureConfig.formbricks.showOn, + } + : null; + + return { + showFeedbackDialog, + feedbackDialogProps, + onFeedbackComplete, + }; +} + +export { useOptInFeedback }; diff --git a/apps/web/modules/feature-opt-in/lib/feature-opt-in-storage.ts b/apps/web/modules/feature-opt-in/lib/feature-opt-in-storage.ts index 238881f7c6..9f4a5b1325 100644 --- a/apps/web/modules/feature-opt-in/lib/feature-opt-in-storage.ts +++ b/apps/web/modules/feature-opt-in/lib/feature-opt-in-storage.ts @@ -5,6 +5,7 @@ import { z } from "zod"; const DISMISSED_STORAGE_KEY = "feature-opt-in-dismissed"; const OPTED_IN_STORAGE_KEY = "feature-opt-in-enabled"; +const FEEDBACK_SHOWN_STORAGE_KEY = "feature-opt-in-feedback-shown"; const booleanFeaturesMapSchema: z.ZodSchema> = z.record(z.string(), z.boolean()); const timestampFeaturesMapSchema: z.ZodSchema> = z.record(z.string(), z.number()); @@ -80,9 +81,19 @@ function setFeatureOptedIn(featureId: string): void { setTimestampFeatureInMap(OPTED_IN_STORAGE_KEY, featureId, Date.now()); } +function isFeatureFeedbackShown(featureId: string): boolean { + return isBooleanFeatureInMap(FEEDBACK_SHOWN_STORAGE_KEY, featureId); +} + +function setFeatureFeedbackShown(featureId: string): void { + setBooleanFeatureInMap(FEEDBACK_SHOWN_STORAGE_KEY, featureId, true); +} + export { getFeatureOptInTimestamp, isFeatureDismissed, + isFeatureFeedbackShown, setFeatureDismissed, setFeatureOptedIn, + setFeatureFeedbackShown, }; diff --git a/apps/web/modules/formbricks/hooks/useFormbricks.ts b/apps/web/modules/formbricks/hooks/useFormbricks.ts index 1c05fdeedc..36daee8f74 100644 --- a/apps/web/modules/formbricks/hooks/useFormbricks.ts +++ b/apps/web/modules/formbricks/hooks/useFormbricks.ts @@ -1,17 +1,17 @@ -import formbricks from "@formbricks/js/app"; +import formbricks from "@formbricks/js"; import { useSession } from "next-auth/react"; import { useEffect } from "react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; -const initFormbricks = ({ +const initFormbricks = async ({ userId, attributes, }: { userId: string; attributes: { [key: string]: string | null | undefined }; }) => { - const filteredAttributes: Record = {}; + const filteredAttributes: Record = {}; Object.entries(attributes).forEach(([key, value]) => { if (value !== null && value !== undefined) { filteredAttributes[key] = value; @@ -19,13 +19,12 @@ const initFormbricks = ({ }); if (process.env.NEXT_PUBLIC_FORMBRICKS_HOST_URL && process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) { - formbricks.init({ + await formbricks.setup({ environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, - apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_HOST_URL, - debug: process.env.NODE_ENV === "development", - userId, - attributes: filteredAttributes, + appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_HOST_URL, }); + await formbricks.setUserId(userId); + await formbricks.setAttributes(filteredAttributes); } }; diff --git a/apps/web/modules/formbricks/lib/trackFormbricksAction.ts b/apps/web/modules/formbricks/lib/trackFormbricksAction.ts index e65b220e99..4c3657d037 100644 --- a/apps/web/modules/formbricks/lib/trackFormbricksAction.ts +++ b/apps/web/modules/formbricks/lib/trackFormbricksAction.ts @@ -1,7 +1,10 @@ -import formbricks from "@formbricks/js/app"; +import formbricks from "@formbricks/js"; -export const trackFormbricksAction = (eventName: string, properties: Record = {}) => { +export const trackFormbricksAction = ( + eventName: string, + hiddenFields?: Record +) => { if (process.env.NEXT_PUBLIC_FORMBRICKS_HOST_URL && process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) { - formbricks.track(eventName, properties); + formbricks.track(eventName, hiddenFields ? { hiddenFields } : undefined); } }; diff --git a/apps/web/pages/api/trpc/feedback/[trpc].ts b/apps/web/pages/api/trpc/feedback/[trpc].ts new file mode 100644 index 0000000000..f2af057088 --- /dev/null +++ b/apps/web/pages/api/trpc/feedback/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { feedbackRouter } from "@calcom/trpc/server/routers/viewer/feedback/_router"; + +export default createNextApiHandler(feedbackRouter); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e146e10b3b..5835684331 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -134,6 +134,17 @@ "bookings_v3_title": "Try the new bookings page", "bookings_v3_name": "Enhanced bookings", "bookings_v3_description": "A redesigned booking page including a calendar view.", + "bookings_v3_feedback_title": "How's the new bookings page?", + "bookings_v3_feedback_description": "Share your experience with the enhanced bookings page.", + "feedback_dialog_title": "How's your experience?", + "feedback_dialog_description": "We'd love to hear your thoughts on this new feature.", + "feedback_rating_question": "How would you rate your experience?", + "feedback_comment_question": "Any additional feedback? (optional)", + "feedback_comment_placeholder": "Tell us what you think (optional)", + "submit_feedback": "Submit feedback", + "feedback_submitted_title": "Thank you!", + "feedback_submitted_description": "Your feedback helps us improve.", + "error_submitting_feedback": "Failed to submit feedback. Please try again.", "request_body_end_time_internal_error": "Internal Error. Request body does not contain end time", "create_calendar_event_error": "Unable to create calendar event in organizer's calendar", "update_calendar_event_error": "Unable to update calendar event.", diff --git a/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx b/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx index 63d501cd48..9dc20582cc 100644 --- a/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx +++ b/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx @@ -1,9 +1,9 @@ "use client"; import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { FeedbackDialog } from "@calcom/features/feedback/components/FeedbackDialog"; import type { ReactElement } from "react"; import { createPortal } from "react-dom"; - import { FeatureOptInBanner } from "./FeatureOptInBanner"; import type { FeatureOptInMutations } from "./FeatureOptInConfirmDialog"; import { FeatureOptInConfirmDialog } from "./FeatureOptInConfirmDialog"; @@ -15,6 +15,19 @@ type UserRoleContext = { adminTeamNames: { id: number; name: string }[]; }; +type FeedbackState = { + showFeedbackDialog: boolean; + feedbackDialogProps: { + surveyId: string; + ratingQuestionId: string; + commentQuestionId: string; + titleKey?: string; + descriptionKey?: string; + showOn?: "all" | "desktop" | "mobile"; + } | null; + onFeedbackComplete: () => void; +}; + type FeatureOptInTrackingData = { enableFor: "user" | "organization" | "teams"; teamCount?: number; @@ -34,6 +47,7 @@ type FeatureOptInBannerState = { dismiss: () => void; markOptedIn: () => void; mutations: FeatureOptInMutations; + feedback: FeedbackState; trackFeatureEnabled: (data: FeatureOptInTrackingData) => void; }; @@ -70,6 +84,19 @@ function FeatureOptInBannerWrapper({ state }: FeatureOptInBannerWrapperProps): R onTrackFeatureEnabled={state.trackFeatureEnabled} /> )} + {state.feedback.feedbackDialogProps && ( + + )} ); } diff --git a/packages/features/feature-opt-in/config.ts b/packages/features/feature-opt-in/config.ts index 73429af75f..f9aaf16b7a 100644 --- a/packages/features/feature-opt-in/config.ts +++ b/packages/features/feature-opt-in/config.ts @@ -30,6 +30,30 @@ export interface OptInFeatureConfig { * Use [] if you want the feature defined but not displayed anywhere. */ displayLocations?: OptInFeatureDisplayLocation[]; + /** + * Formbricks feedback configuration for delayed survey triggering after opt-in. + * When configured, a custom feedback dialog will be shown after the specified delay + * has passed since the user opted in, allowing time for feature usage before collecting feedback. + */ + formbricks?: { + /** Number of days to wait after opt-in before showing the feedback dialog */ + waitAfterDays: number; + /** Where to show the feedback dialog: "all" | "desktop" | "mobile". Defaults to "all". */ + showOn?: "all" | "desktop" | "mobile"; + /** Formbricks survey ID to submit responses to */ + surveyId: string; + /** Question IDs for the survey fields */ + questions: { + /** Question ID for the rating field */ + ratingQuestionId: string; + /** Question ID for the comment field */ + commentQuestionId: string; + }; + /** i18n key for the feedback dialog title (defaults to "feedback_dialog_title") */ + titleKey?: string; + /** i18n key for the feedback dialog description (defaults to "feedback_dialog_description") */ + descriptionKey?: string; + }; } /** @@ -52,6 +76,17 @@ export const OPT_IN_FEATURES: OptInFeatureConfig[] = [ policy: "permissive", displayLocations: ["settings"], scope: ["org", "team", "user"], // Optional: defaults to all scopes if not specified + formbricks: { + waitAfterDays: 3, + showOn: "desktop", + surveyId: "cml6ps4f0psk9ad019a2kzedz", + questions: { + ratingQuestionId: "ajt82ni0hue3x5qkltj9t359", + commentQuestionId: "kwjw0g7vqkgd5w9s61dba8de", + }, + titleKey: "bookings_v3_feedback_title", + descriptionKey: "bookings_v3_feedback_description", + }, }, ]; diff --git a/packages/features/feedback/components/FeedbackDialog.tsx b/packages/features/feedback/components/FeedbackDialog.tsx new file mode 100644 index 0000000000..21b31c9d76 --- /dev/null +++ b/packages/features/feedback/components/FeedbackDialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@coss/ui/components/button"; +import { Textarea } from "@coss/ui/components/textarea"; +import { toastManager } from "@coss/ui/components/toast"; +import { cn } from "@coss/ui/lib/utils"; +import { XIcon } from "lucide-react"; +import type { ReactElement } from "react"; +import { useState } from "react"; +import { RATING_OPTIONS } from "../../bookings/lib/rating"; + +export interface FeedbackDialogProps { + isOpen: boolean; + onClose: () => void; + onSubmitSuccess?: () => void; + surveyId: string; + ratingQuestionId: string; + commentQuestionId: string; + titleKey?: string; + descriptionKey?: string; + /** Where to show the dialog: "all" | "desktop" | "mobile". Defaults to "all". */ + showOn?: "all" | "desktop" | "mobile"; +} + +/** + * Bottom-right positioned feedback card (non-blocking). + * Styled similar to the feature opt-in banner. + */ +export function FeedbackDialog({ + isOpen, + onClose, + onSubmitSuccess, + surveyId, + ratingQuestionId, + commentQuestionId, + titleKey = "feedback_dialog_title", + descriptionKey = "feedback_dialog_description", + showOn = "all", +}: FeedbackDialogProps): ReactElement | null { + const { t } = useLocale(); + const [selectedRating, setSelectedRating] = useState(null); + const [comment, setComment] = useState(""); + const [isSuccess, setIsSuccess] = useState(false); + + const submitFeedbackMutation = trpc.viewer.feedback.submitFeedback.useMutation({ + onSuccess: () => { + setIsSuccess(true); + onSubmitSuccess?.(); + }, + onError: () => { + toastManager.add({ title: t("error_submitting_feedback"), type: "error" }); + }, + }); + + const handleSubmit = async (): Promise => { + if (selectedRating === null) return; + + await submitFeedbackMutation.mutateAsync({ + surveyId, + data: { + [ratingQuestionId]: selectedRating, + [commentQuestionId]: comment, + }, + }); + }; + + const handleSkip = (): void => { + resetAndClose(); + }; + + const resetAndClose = (): void => { + setSelectedRating(null); + setComment(""); + setIsSuccess(false); + onClose(); + }; + + if (!isOpen) { + return null; + } + + // Visibility classes based on showOn + const visibilityClass = showOn === "desktop" ? "hidden sm:block" : showOn === "mobile" ? "sm:hidden" : ""; + const showMobileBackdrop = showOn !== "desktop"; + + if (isSuccess) { + return ( + <> + {/* Mobile-only backdrop */} + {showMobileBackdrop && ( +
+ )} +
+
+ +

{t("feedback_submitted_title")}

+

{t("feedback_submitted_description")}

+
+ +
+
+
+ + ); + } + + return ( + <> + {/* Mobile-only backdrop */} + {showMobileBackdrop && ( +
+ )} +
+
+ +

{t(titleKey)}

+

{t(descriptionKey)}

+ +
+ {RATING_OPTIONS.map((option) => ( + + ))} +
+ +