* 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>
203 lines
7.1 KiB
TypeScript
203 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
|
import { getOptInFeatureConfig, shouldDisplayFeatureAt } from "@calcom/features/feature-opt-in/config";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
import posthog from "posthog-js";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
getFeatureOptInTimestamp,
|
|
isFeatureDismissed,
|
|
setFeatureDismissed,
|
|
setFeatureOptedIn,
|
|
} from "../lib/feature-opt-in-storage";
|
|
import type { OptInFeedbackState } from "./useOptInFeedback";
|
|
import { useOptInFeedback } from "./useOptInFeedback";
|
|
|
|
type UserRoleContext = {
|
|
isOrgAdmin: boolean;
|
|
orgId: number | null;
|
|
adminTeamIds: number[];
|
|
adminTeamNames: { id: number; name: string }[];
|
|
};
|
|
|
|
type FeatureOptInMutations = {
|
|
setUserState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
|
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise<unknown>;
|
|
setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
|
setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
|
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise<unknown>;
|
|
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
|
invalidateQueries: () => void;
|
|
};
|
|
|
|
type FeatureOptInTrackingData = {
|
|
enableFor: "user" | "organization" | "teams";
|
|
teamCount?: number;
|
|
autoOptIn: boolean;
|
|
};
|
|
|
|
type UseFeatureOptInBannerResult = {
|
|
shouldShow: boolean;
|
|
isLoading: boolean;
|
|
featureConfig: OptInFeatureConfig | null;
|
|
canOptIn: boolean;
|
|
blockingReason: string | null;
|
|
userRoleContext: UserRoleContext | null;
|
|
isDialogOpen: boolean;
|
|
openDialog: () => void;
|
|
closeDialog: () => void;
|
|
dismiss: () => void;
|
|
markOptedIn: () => void;
|
|
mutations: FeatureOptInMutations;
|
|
feedback: OptInFeedbackState;
|
|
trackFeatureEnabled: (data: FeatureOptInTrackingData) => void;
|
|
};
|
|
|
|
function isFeatureOptedIn(featureId: string): boolean {
|
|
return getFeatureOptInTimestamp(featureId) !== null;
|
|
}
|
|
|
|
function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult {
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [isDismissed, setIsDismissed] = useState(() => isFeatureDismissed(featureId));
|
|
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(
|
|
{ featureId },
|
|
{
|
|
enabled: !isDismissed && !isOptedIn && featureConfig !== null,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 1000 * 60 * 5,
|
|
}
|
|
);
|
|
|
|
// When the server reports the feature is already enabled, cache it locally
|
|
// to avoid repeated API calls on subsequent page loads
|
|
useEffect(() => {
|
|
if (eligibilityQuery.data?.status === "already_enabled") {
|
|
setFeatureOptedIn(featureId);
|
|
setIsOptedIn(true);
|
|
}
|
|
}, [eligibilityQuery.data?.status, featureId]);
|
|
|
|
const setUserStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation();
|
|
const setTeamStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation();
|
|
const setOrganizationStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation();
|
|
const setUserAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation();
|
|
const setTeamAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation();
|
|
const setOrganizationAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation();
|
|
|
|
const mutations: FeatureOptInMutations = useMemo(
|
|
() => ({
|
|
setUserState: (params: { slug: string; state: "enabled" }) => setUserStateMutation.mutateAsync(params),
|
|
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) =>
|
|
setTeamStateMutation.mutateAsync(params),
|
|
setOrganizationState: (params: { slug: string; state: "enabled" }) =>
|
|
setOrganizationStateMutation.mutateAsync(params),
|
|
setUserAutoOptIn: (params: { autoOptIn: boolean }) => setUserAutoOptInMutation.mutateAsync(params),
|
|
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) =>
|
|
setTeamAutoOptInMutation.mutateAsync(params),
|
|
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) =>
|
|
setOrganizationAutoOptInMutation.mutateAsync(params),
|
|
invalidateQueries: (): void => {
|
|
utils.viewer.featureOptIn.checkFeatureOptInEligibility.invalidate();
|
|
utils.viewer.featureOptIn.listForUser.invalidate();
|
|
},
|
|
}),
|
|
[
|
|
setUserStateMutation,
|
|
setTeamStateMutation,
|
|
setOrganizationStateMutation,
|
|
setUserAutoOptInMutation,
|
|
setTeamAutoOptInMutation,
|
|
setOrganizationAutoOptInMutation,
|
|
utils,
|
|
]
|
|
);
|
|
|
|
const hasBannerShownBeenTracked = useRef(false);
|
|
|
|
const dismiss = useCallback(() => {
|
|
posthog.capture("feature_opt_in_banner_dismissed", {
|
|
feature_slug: featureId,
|
|
});
|
|
setFeatureDismissed(featureId);
|
|
setIsDismissed(true);
|
|
}, [featureId]);
|
|
|
|
const markOptedIn = useCallback(() => {
|
|
setFeatureOptedIn(featureId);
|
|
setIsOptedIn(true);
|
|
}, [featureId]);
|
|
|
|
const openDialog = useCallback(() => {
|
|
posthog.capture("feature_opt_in_banner_try_it_clicked", {
|
|
feature_slug: featureId,
|
|
});
|
|
setIsDialogOpen(true);
|
|
}, [featureId]);
|
|
|
|
const closeDialog = useCallback(() => {
|
|
setIsDialogOpen(false);
|
|
}, []);
|
|
|
|
const trackFeatureEnabled = useCallback(
|
|
(data: FeatureOptInTrackingData) => {
|
|
posthog.capture("feature_opt_in_enabled", {
|
|
feature_slug: featureId,
|
|
enable_for: data.enableFor,
|
|
team_count: data.teamCount,
|
|
auto_opt_in: data.autoOptIn,
|
|
});
|
|
},
|
|
[featureId]
|
|
);
|
|
|
|
const shouldShow = useMemo(() => {
|
|
if (isDismissed) return false;
|
|
if (isOptedIn) return false;
|
|
if (!featureConfig) return false;
|
|
// Only show banner if the feature is configured to be displayed as a banner
|
|
if (!shouldDisplayFeatureAt(featureConfig, "banner")) return false;
|
|
if (eligibilityQuery.isLoading) return false;
|
|
if (!eligibilityQuery.data) return false;
|
|
return eligibilityQuery.data.status === "can_opt_in";
|
|
}, [isDismissed, isOptedIn, featureConfig, eligibilityQuery.isLoading, eligibilityQuery.data]);
|
|
|
|
useEffect(() => {
|
|
if (shouldShow && !hasBannerShownBeenTracked.current) {
|
|
hasBannerShownBeenTracked.current = true;
|
|
posthog.capture("feature_opt_in_banner_shown", {
|
|
feature_slug: featureId,
|
|
});
|
|
}
|
|
}, [shouldShow, featureId]);
|
|
|
|
return {
|
|
shouldShow,
|
|
isLoading: eligibilityQuery.isLoading,
|
|
featureConfig,
|
|
canOptIn: eligibilityQuery.data?.canOptIn ?? false,
|
|
blockingReason: eligibilityQuery.data?.blockingReason ?? null,
|
|
userRoleContext: eligibilityQuery.data?.userRoleContext ?? null,
|
|
isDialogOpen,
|
|
openDialog,
|
|
closeDialog,
|
|
dismiss,
|
|
markOptedIn,
|
|
mutations,
|
|
feedback,
|
|
trackFeatureEnabled,
|
|
};
|
|
}
|
|
|
|
export { useFeatureOptInBanner };
|
|
export type { FeatureOptInMutations, UseFeatureOptInBannerResult };
|