Files
calendar/packages/features/feedback/components/FeedbackDialog.tsx
T
Eunjae LeeGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2364cff54d 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>
2026-02-05 11:01:14 +00:00

187 lines
5.8 KiB
TypeScript

"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;