* 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>
187 lines
5.8 KiB
TypeScript
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;
|