feat: custom feedback dialog for feature opt-in (#27578)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor: simplify feedback dialog UI - Remove redundant question labels - Add "(optional)" to comment placeholder Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
Devin AI
parent
80e07a8d35
commit
2364cff54d
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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<Record<string, boolean>> = z.record(z.string(), z.boolean());
|
||||
const timestampFeaturesMapSchema: z.ZodSchema<Record<string, number>> = 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,
|
||||
};
|
||||
|
||||
@@ -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<string, string | number> = {};
|
||||
const filteredAttributes: Record<string, string> = {};
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import formbricks from "@formbricks/js/app";
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
export const trackFormbricksAction = (eventName: string, properties: Record<string, string> = {}) => {
|
||||
export const trackFormbricksAction = (
|
||||
eventName: string,
|
||||
hiddenFields?: Record<string | number, string | number | string[]>
|
||||
) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 && (
|
||||
<FeedbackDialog
|
||||
isOpen={state.feedback.showFeedbackDialog}
|
||||
onClose={state.feedback.onFeedbackComplete}
|
||||
onSubmitSuccess={state.feedback.onFeedbackComplete}
|
||||
surveyId={state.feedback.feedbackDialogProps.surveyId}
|
||||
ratingQuestionId={state.feedback.feedbackDialogProps.ratingQuestionId}
|
||||
commentQuestionId={state.feedback.feedbackDialogProps.commentQuestionId}
|
||||
titleKey={state.feedback.feedbackDialogProps.titleKey}
|
||||
descriptionKey={state.feedback.feedbackDialogProps.descriptionKey}
|
||||
showOn={state.feedback.feedbackDialogProps.showOn}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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<number | null>(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<void> => {
|
||||
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 && (
|
||||
<div className="fixed inset-0 z-40 bg-black/50 sm:hidden" onClick={resetAndClose} />
|
||||
)}
|
||||
<div
|
||||
data-testid="feedback-success-dialog"
|
||||
className={cn(
|
||||
"bg-default border-subtle fixed bottom-24 left-5 right-5 z-50 rounded-lg border shadow-lg sm:bottom-5 sm:left-auto sm:max-w-sm",
|
||||
visibilityClass
|
||||
)}>
|
||||
<div className="relative p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAndClose}
|
||||
className="absolute top-2 right-2 rounded-md p-1 hover:bg-subtle"
|
||||
aria-label={t("close")}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="text-emphasis text-lg font-semibold">{t("feedback_submitted_title")}</h3>
|
||||
<p className="text-subtle mt-1 text-sm">{t("feedback_submitted_description")}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button size="sm" onClick={resetAndClose}>
|
||||
{t("done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile-only backdrop */}
|
||||
{showMobileBackdrop && (
|
||||
<div className="fixed inset-0 z-40 bg-black/50 sm:hidden" onClick={handleSkip} />
|
||||
)}
|
||||
<div
|
||||
data-testid="feedback-dialog"
|
||||
className={cn(
|
||||
"bg-default border-subtle fixed bottom-24 left-5 right-5 z-50 rounded-lg border shadow-lg sm:bottom-5 sm:left-auto sm:max-w-sm",
|
||||
visibilityClass
|
||||
)}>
|
||||
<div className="relative p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="absolute top-2 right-2 rounded-md p-1 hover:bg-subtle"
|
||||
aria-label={t("close")}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="text-emphasis text-lg font-semibold">{t(titleKey)}</h3>
|
||||
<p className="text-subtle mt-1 text-sm">{t(descriptionKey)}</p>
|
||||
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
{RATING_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedRating(option.value)}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg text-xl transition-all",
|
||||
selectedRating === option.value ? "bg-emphasis" : "hover:bg-subtle"
|
||||
)}
|
||||
aria-label={`Rating ${option.value}`}>
|
||||
{option.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
className="mt-3"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={t("feedback_comment_placeholder")}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleSkip}>
|
||||
{t("skip")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedRating === null || submitFeedbackMutation.isPending}>
|
||||
{t("submit_feedback")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedbackDialog;
|
||||
@@ -4,8 +4,8 @@ import type { Feedback } from "@calcom/emails/templates/feedback-email";
|
||||
|
||||
enum Rating {
|
||||
"Extremely unsatisfied" = 1,
|
||||
"Unsatisfied" = 2,
|
||||
"Satisfied" = 3,
|
||||
Unsatisfied = 2,
|
||||
Satisfied = 3,
|
||||
"Extremely satisfied" = 4,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"dependencies": {
|
||||
"@calcom/config": "workspace:*",
|
||||
"@calcom/dayjs": "workspace:*",
|
||||
"@formbricks/api": "1.7.0",
|
||||
"@formbricks/js": "2.1.0",
|
||||
"@formbricks/api": "3.0.0",
|
||||
"@formbricks/js": "4.3.0",
|
||||
"@sendgrid/client": "7.7.0",
|
||||
"@unkey/ratelimit": "2.1.3",
|
||||
"@vercel/og": "0.6.3",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"build:react": "yarn tsc --project tsconfig.react.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/api": "3.0.0",
|
||||
"@trpc/client": "11.0.0-next-beta.222",
|
||||
"@trpc/next": "11.0.0-next-beta.222",
|
||||
"@trpc/react-query": "11.0.0-next-beta.222",
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ENDPOINTS = [
|
||||
"eventTypes",
|
||||
"eventTypesHeavy",
|
||||
"features",
|
||||
"feedback",
|
||||
"holidays",
|
||||
"featureOptIn",
|
||||
"i18n",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { featureFlagRouter } from "@calcom/features/flags/server/router";
|
||||
|
||||
import { router } from "../../trpc";
|
||||
import app_RoutingForms from "../apps/routing-forms/_router";
|
||||
import { loggedInViewerRouter } from "../loggedInViewer/_router";
|
||||
@@ -9,13 +8,13 @@ import { adminRouter } from "./admin/_router";
|
||||
import { aiVoiceAgentRouter } from "./aiVoiceAgent/_router";
|
||||
import { apiKeysRouter } from "./apiKeys/_router";
|
||||
import { appsRouter } from "./apps/_router";
|
||||
import { attributesRouter } from "./attributes/_router";
|
||||
import { attributeSyncRouter } from "./attribute-sync/_router";
|
||||
import { attributesRouter } from "./attributes/_router";
|
||||
import { authRouter } from "./auth/_router";
|
||||
import { availabilityRouter } from "./availability/_router";
|
||||
import { bookingsRouter } from "./bookings/_router";
|
||||
import { calVideoRouter } from "./calVideo/_router";
|
||||
import { calendarsRouter } from "./calendars/_router";
|
||||
import { calVideoRouter } from "./calVideo/_router";
|
||||
import { credentialsRouter } from "./credentials/_router";
|
||||
import { creditsRouter } from "./credits/_router";
|
||||
import { delegationCredentialRouter } from "./delegationCredential/_router";
|
||||
@@ -24,6 +23,7 @@ import { dsyncRouter } from "./dsync/_router";
|
||||
import { eventTypesRouter } from "./eventTypes/_router";
|
||||
import { eventTypesRouter as heavyEventTypesRouter } from "./eventTypes/heavy/_router";
|
||||
import { featureOptInRouter } from "./featureOptIn/_router";
|
||||
import { feedbackRouter } from "./feedback/_router";
|
||||
import { filterSegmentsRouter } from "./filterSegments/_router";
|
||||
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
||||
import { holidaysRouter } from "./holidays/_router";
|
||||
@@ -79,6 +79,7 @@ export const viewerRouter = router({
|
||||
appRoutingForms: app_RoutingForms,
|
||||
features: featureFlagRouter,
|
||||
featureOptIn: featureOptInRouter,
|
||||
feedback: feedbackRouter,
|
||||
users: userAdminRouter,
|
||||
oAuth: oAuthRouter,
|
||||
googleWorkspace: googleWorkspaceRouter,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { z } from "zod";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["feedback"] });
|
||||
|
||||
export const feedbackRouter = router({
|
||||
/**
|
||||
* Submit feedback to Formbricks.
|
||||
* This allows us to use our own UI while still sending data to Formbricks.
|
||||
*/
|
||||
submitFeedback: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
surveyId: z.string(),
|
||||
data: z.record(z.string(), z.union([z.string(), z.number()])),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const hostUrl = process.env.NEXT_PUBLIC_FORMBRICKS_HOST_URL;
|
||||
const environmentId = process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
|
||||
|
||||
if (!hostUrl || !environmentId) {
|
||||
// Silently succeed if Formbricks is not configured
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: hostUrl,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const result = await api.client.response.create({
|
||||
surveyId: input.surveyId,
|
||||
userId: ctx.user.id.toString(),
|
||||
finished: true,
|
||||
data: input.data,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
log.error("Formbricks API error", { error: result.error });
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -2847,8 +2847,8 @@ __metadata:
|
||||
"@calcom/tsconfig": "workspace:*"
|
||||
"@calcom/types": "workspace:*"
|
||||
"@faker-js/faker": "npm:7.6.0"
|
||||
"@formbricks/api": "npm:1.7.0"
|
||||
"@formbricks/js": "npm:2.1.0"
|
||||
"@formbricks/api": "npm:3.0.0"
|
||||
"@formbricks/js": "npm:4.3.0"
|
||||
"@sendgrid/client": "npm:7.7.0"
|
||||
"@unkey/ratelimit": "npm:2.1.3"
|
||||
"@vercel/og": "npm:0.6.3"
|
||||
@@ -3340,6 +3340,7 @@ __metadata:
|
||||
resolution: "@calcom/trpc@workspace:packages/trpc"
|
||||
dependencies:
|
||||
"@biomejs/biome": "npm:2.3.10"
|
||||
"@formbricks/api": "npm:3.0.0"
|
||||
"@tanstack/react-query": "npm:5.17.19"
|
||||
"@trpc/client": "npm:11.0.0-next-beta.222"
|
||||
"@trpc/next": "npm:11.0.0-next-beta.222"
|
||||
@@ -5208,17 +5209,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formbricks/api@npm:1.7.0":
|
||||
version: 1.7.0
|
||||
resolution: "@formbricks/api@npm:1.7.0"
|
||||
checksum: 10/0f45372b12da4893ac9b75d71a94658420f75dd79e20af32315a33c45d6d4b5bfbeb33c7fb9816b677a3b9d7d105ce5130963d5f15fb7ebd2425d4ec8fb01982
|
||||
"@formbricks/api@npm:3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@formbricks/api@npm:3.0.0"
|
||||
checksum: 10/d20cde3e904eaf62b3886dabfdcd8b51b7018f146325def413ba4c788ad3a5c8fc3be6e0931199b3ab8ba21ba0cd3e2aa2eaae874623fa17c43178d1269ee87a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formbricks/js@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "@formbricks/js@npm:2.1.0"
|
||||
checksum: 10/037743c4d4558e1ee434b283646244f6f594ea0ea6c61533ceb2fc987d0b6260fa45234801309c847af5a9745e1691785a5094f3743e54f3b2ec9c2fb5ccd30e
|
||||
"@formbricks/js@npm:4.3.0":
|
||||
version: 4.3.0
|
||||
resolution: "@formbricks/js@npm:4.3.0"
|
||||
checksum: 10/9ab7e85963f03f095c4c7edd14670258afe5c1c9010137a8bf33fb09bed96a68ff6449e5949b4ea8dccce2c33d02cdd7c426034e422b675df28d6750ebdd8b84
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user