Files
calendar/apps/web/modules/feature-opt-in/lib/feature-opt-in-storage.ts
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

100 lines
3.0 KiB
TypeScript

"use client";
import { localStorage } from "@calcom/lib/webstorage";
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());
type BooleanFeaturesMap = z.infer<typeof booleanFeaturesMapSchema>;
type TimestampFeaturesMap = z.infer<typeof timestampFeaturesMapSchema>;
function getBooleanFeaturesMap(storageKey: string): BooleanFeaturesMap {
const stored = localStorage.getItem(storageKey);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
const result = booleanFeaturesMapSchema.safeParse(parsed);
if (result.success) {
return result.data;
}
return {};
} catch {
return {};
}
}
function getTimestampFeaturesMap(storageKey: string): TimestampFeaturesMap {
const stored = localStorage.getItem(storageKey);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
const result = timestampFeaturesMapSchema.safeParse(parsed);
if (result.success) {
return result.data;
}
return {};
} catch {
return {};
}
}
function setBooleanFeatureInMap(storageKey: string, featureId: string, value: boolean): void {
const current = getBooleanFeaturesMap(storageKey);
current[featureId] = value;
localStorage.setItem(storageKey, JSON.stringify(current));
}
function setTimestampFeatureInMap(storageKey: string, featureId: string, timestamp: number): void {
const current = getTimestampFeaturesMap(storageKey);
current[featureId] = timestamp;
localStorage.setItem(storageKey, JSON.stringify(current));
}
function isBooleanFeatureInMap(storageKey: string, featureId: string): boolean {
const features = getBooleanFeaturesMap(storageKey);
return features[featureId] === true;
}
function getFeatureOptInTimestamp(featureId: string): number | null {
const features = getTimestampFeaturesMap(OPTED_IN_STORAGE_KEY);
const timestamp = features[featureId];
if (typeof timestamp === "number") {
return timestamp;
}
return null;
}
function isFeatureDismissed(featureId: string): boolean {
return isBooleanFeatureInMap(DISMISSED_STORAGE_KEY, featureId);
}
function setFeatureDismissed(featureId: string): void {
setBooleanFeatureInMap(DISMISSED_STORAGE_KEY, featureId, true);
}
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,
};