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:
Eunjae Lee
2026-02-05 11:01:14 +00:00
committed by GitHub
co-authored by Claude Opus 4.5 Devin AI
parent 80e07a8d35
commit 2364cff54d
17 changed files with 467 additions and 29 deletions
@@ -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;
+2 -2
View File
@@ -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,
}
+2 -2
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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 };
}),
});
+11 -10
View File
@@ -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