feat: gated features modal (#25296)

* feat: Modal for gated features

* use zustand

* refactor

* Apply suggestions from code review

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/ui/components/card/Card.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* dismiss button fix and url fix

* fix: broken redirect to teams

* fix: type check and review comments

* add learn more tracking back

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Amit Sharma
2025-12-11 11:14:31 +01:00
committed by GitHub
co-authored by cubic-dev-ai[bot] Udit Takkar
parent 191dcc45b5
commit 44104e1ba4
7 changed files with 354 additions and 167 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 429 KiB

@@ -4093,6 +4093,11 @@
"onboarding_teams_browser_view_no_teams_title": "No teams yet",
"onboarding_teams_browser_view_no_teams_description": "Add teams to get started",
"book_now": "Book now",
"only_available_on_orgs_plan": "This feature is available on the Organization plan",
"upgrade_team_to_orgs_with_price": "Upgrade your team to get access to this and much more for $37 per month/user.",
"attribute_based_routing": "Attribute-based routing",
"compliance_check": "SOC2, HIPAA, ISO 27001 compliance check",
"and_more_features": "and more features...",
"view": "View",
"booking_response": "Booking Response",
"list_view": "List view",
@@ -3,6 +3,7 @@
import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal";
import { WelcomeToCalcomModal } from "./components/WelcomeToCalcomModal";
import { GatedFeaturesModal } from "./components/GatedFeaturesModal";
/**
* Container for all query-param driven modals that should appear globally across the app.
@@ -20,6 +21,7 @@ export function DynamicModals() {
<WelcomeToOrganizationsModal />
<WelcomeToCalcomModal />
{/* Add more query-param driven modals here */}
<GatedFeaturesModal />
</>
);
}
@@ -0,0 +1,77 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Button } from "@calcom/ui/components/button";
import { useGatedFeaturesStore, GatedFeatures } from "../stores/gatedFeaturesStore";
type FeatureContent = {
badgeText: string;
title: string;
image: string;
description: string;
features: string[];
learnMoreUrl: string;
ctaUrl: string;
};
const content: Record<GatedFeatures, FeatureContent> = {
roles_and_permissions: {
badgeText: "roles_and_permissions",
title: "only_available_on_orgs_plan",
image: "/gated-features/roles_and_permissions.svg",
description: "upgrade_team_to_orgs_with_price",
features: [
"1_parent_team_unlimited_subteams",
"attribute_based_routing",
"compliance_check",
"and_more_features",
],
learnMoreUrl: "https://cal.com/enterprise",
ctaUrl: "/settings/organizations/new",
},
};
export function GatedFeaturesModal() {
const { t } = useLocale();
const isOpen = useGatedFeaturesStore((state) => state.isOpen);
const activeFeature = useGatedFeaturesStore((state) => state.activeFeature);
const close = useGatedFeaturesStore((state) => state.close);
const data = activeFeature ? content[activeFeature] : null;
if (!data) {
return null;
}
const { badgeText, description, image, title, features, learnMoreUrl, ctaUrl } = data;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
<DialogContent className="p-0!" style={{ maxWidth: "400px" }}>
<div className="p-6">
<img src={image} alt={t(title)} className="w-full h-36 mb-5" />
<div className="p-1 text-xs bg-emphasis rounded-md w-fit mb-3 leading-3">{t(badgeText)}</div>
<div className="mb-5 flex flex-col gap-2">
<div className="text-xl font-semibold text-emphasis leading-6">{t(title)}</div>
<div className="text-sm leading-5 font-normal text-default">{t(description)}</div>
</div>
<div className="flex flex-col gap-1 ml-6">
<ul className="list-disc">
{features.map((feature) => (
<li className="text-sm text-default font-medium" key={feature}>{t(feature)}</li>
))}
</ul>
</div>
</div>
<div className="w-full bg-muted border-subtle flex items-center justify-between rounded-b-2xl border-t px-6 py-5">
<Button color="minimal" onClick={close}>{t("dismiss")}</Button>
<div className="flex gap-2">
<Button color="secondary" target="_blank" rel="noopener noreferrer" href={learnMoreUrl}>{t("learn_more")}</Button>
<Button color="primary" href={ctaUrl}>{t("upgrade")}</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,19 @@
import { create } from "zustand";
export enum GatedFeatures {
RolesAndPermissions = "roles_and_permissions",
}
type GatedFeaturesStore = {
isOpen: boolean;
activeFeature: GatedFeatures | null;
open: (feature: GatedFeatures) => void;
close: () => void;
};
export const useGatedFeaturesStore = create<GatedFeaturesStore>((set) => ({
isOpen: false,
activeFeature: null,
open: (feature) => set({ isOpen: true, activeFeature: feature }),
close: () => set({ isOpen: false, activeFeature: null }),
}));
+169 -155
View File
@@ -6,6 +6,8 @@ import posthog from "posthog-js";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { localStorage } from "@calcom/lib/webstorage";
import { Card } from "@calcom/ui/components/card";
import { GatedFeatures } from "@calcom/features/shell/stores/gatedFeaturesStore";
import { useGatedFeaturesStore } from "@calcom/features/shell/stores/gatedFeaturesStore";
type Tip = {
id: number;
@@ -13,154 +15,164 @@ type Tip = {
mediaLink?: string;
title: string;
description: string;
href: string;
href?: string;
onClick?: () => void;
coverPhoto?: string;
variant?: "SidebarCard" | "NewLaunchSidebarCard";
};
export const tips: Tip[] = [
{
id: 17,
thumbnailUrl: "https://img.youtube.com/vi/fMHW6jYPIb8/0.jpg",
mediaLink: "https://go.cal.com/embed-video",
title: "Embed",
description: "Embed your booking page on your website",
href: "https://go.cal.com/embed-video",
},
{
id: 16,
thumbnailUrl: "https://img.youtube.com/vi/xopxmk2H4Ng/0.jpg",
mediaLink: "https://go.cal.com/paid-booking",
title: "Paid Bookings",
description: "Charge for your time with Cal.com's paid bookings",
href: "https://go.cal.com/paid-booking",
},
{
id: 15,
thumbnailUrl: "https://img.youtube.com/vi/ZjSD1yPgLLQ/0.jpg",
mediaLink: "https://go.cal.com/instant-meetings-video",
title: "Instant Meetings",
description: "Book meetings instantly with a link",
href: "https://go.cal.com/instant-meetings-video",
},
{
id: 14,
thumbnailUrl: "https://img.youtube.com/vi/IZ4-nUiIpvY/0.jpg",
mediaLink: "https://go.cal.com/custom-attributes",
title: "Custom Attributes",
description: "Define roles and attributes for your teams",
href: "https://go.cal.com/custom-attributes",
},
{
id: 13,
thumbnailUrl: "https://img.youtube.com/vi/TTAASLLPKk0/0.jpg",
mediaLink: "https://go.cal.com/ooo-video",
title: "ooo.new",
description: "Easily go out-of-office",
href: "https://go.cal.com/ooo-video",
},
{
id: 12,
thumbnailUrl: "https://cal.com/og-image-cal-ai.jpg",
mediaLink: "https://go.cal.com/cal-ai",
title: "Cal.ai",
description: "Your personal AI scheduling assistant",
href: "https://go.cal.com/cal-ai",
},
{
id: 11,
thumbnailUrl: "https://img.youtube.com/vi/KTg_qzA9NEc/0.jpg",
mediaLink: "https://go.cal.com/insights",
title: "Insights",
description: "Get a better understanding of your business",
href: "https://go.cal.com/insights",
},
{
id: 10,
thumbnailUrl: "https://img.youtube.com/vi/jvaBafzVUQc/0.jpg",
mediaLink: "https://go.cal.com/video",
title: "Cal Video",
description: "Free video conferencing with recording",
href: "https://go.cal.com/video",
},
{
id: 9,
thumbnailUrl: "https://img.youtube.com/vi/93iOmzHieCU/0.jpg",
mediaLink: "https://go.cal.com/round-robin",
title: "Round-Robin",
description: "Create advanced group meetings with round-robin",
href: "https://go.cal.com/round-robin",
},
{
id: 8,
thumbnailUrl: "https://img.youtube.com/vi/piKlAiibAFo/0.jpg",
mediaLink: "https://go.cal.com/workflows",
title: "Automate Workflows",
description: "Make time work for you and automate tasks",
href: "https://go.cal.com/workflows",
},
{
id: 7,
thumbnailUrl: "https://img.youtube.com/vi/UVXgo12cY4g/0.jpg",
mediaLink: "https://go.cal.com/routing-forms",
title: "Routing Forms",
description: "Ask questions and route to the correct person",
href: "https://go.cal.com/routing-forms",
},
{
id: 6,
thumbnailUrl: "https://img.youtube.com/vi/yGiZo1Ry5-8/0.jpg",
mediaLink: "https://go.cal.com/recurring-video",
title: "Recurring Bookings",
description: "Learn how to create a recurring schedule",
href: "https://go.cal.com/recurring-video",
},
{
id: 5,
thumbnailUrl: "https://img.youtube.com/vi/0v_nQtpxC_4/0.jpg",
mediaLink: "https://go.cal.com/payments-video",
title: "Accept Payments",
description: "Charge for your time with Cal.com's Stripe App",
href: "https://app.cal.com/apps/stripe",
},
{
id: 4,
thumbnailUrl: "https://img.youtube.com/vi/zGr_s-fG84k/0.jpg",
mediaLink: "https://go.cal.com/confirmation-video",
title: "Requires Confirmation",
description: "Learn how to be in charge of your bookings",
href: "https://cal.com/resources/feature/opt-in",
},
{
id: 3,
thumbnailUrl: "https://img.youtube.com/vi/-L8MCiT6uhQ/0.jpg",
mediaLink: "https://youtu.be/-L8MCiT6uhQ?si=DRJzRNhN85hqAwBl",
title: "Routing Forms, Workflows",
description: "Ask screening questions of potential bookers to connect them with the right person",
href: "https://youtu.be/-L8MCiT6uhQ?si=DRJzRNhN85hqAwBl",
},
{
id: 2,
thumbnailUrl: "https://img.youtube.com/vi/EAc46SPL6iA/0.jpg",
mediaLink: "https://go.cal.com/teams-video",
title: "How to set up Teams",
description: "Learn how to use round-robin and collective events.",
href: "https://cal.com/docs/enterprise-features/teams",
},
{
id: 1,
thumbnailUrl: "https://img.youtube.com/vi/VZ5PfQzzxBw/0.jpg",
mediaLink: "https://youtu.be/VZ5PfQzzxBw?si=oB0LPSxG8dWIwg5a",
title: "Dynamic booking links",
description: "Booking link that allows people to quickly schedule meetings.",
href: "https://youtu.be/VZ5PfQzzxBw?si=oB0LPSxG8dWIwg5a",
},
];
const reversedTips = [...shuffle(tips).slice(0).reverse()];
function Tips() {
const { t } = useLocale();
const openModal = useGatedFeaturesStore((state) => state.open);
const tips: Tip[] = [
{
id: 18,
thumbnailUrl: "https://img.youtube.com/vi/J8HsK-8W39U/0.jpg",
mediaLink: "https://go.cal.com/rbac-video",
title: "Roles & Permissions",
description: "Manage team access with roles & permissions",
onClick: () => openModal(GatedFeatures.RolesAndPermissions),
},
{
id: 17,
thumbnailUrl: "https://img.youtube.com/vi/fMHW6jYPIb8/0.jpg",
mediaLink: "https://go.cal.com/embed-video",
title: "Embed",
description: "Embed your booking page on your website",
href: "https://go.cal.com/embed-video",
},
{
id: 16,
thumbnailUrl: "https://img.youtube.com/vi/xopxmk2H4Ng/0.jpg",
mediaLink: "https://go.cal.com/paid-booking",
title: "Paid Bookings",
description: "Charge for your time with Cal.com's paid bookings",
href: "https://go.cal.com/paid-booking",
},
{
id: 15,
thumbnailUrl: "https://img.youtube.com/vi/ZjSD1yPgLLQ/0.jpg",
mediaLink: "https://go.cal.com/instant-meetings-video",
title: "Instant Meetings",
description: "Book meetings instantly with a link",
href: "https://go.cal.com/instant-meetings-video",
},
{
id: 14,
thumbnailUrl: "https://img.youtube.com/vi/IZ4-nUiIpvY/0.jpg",
mediaLink: "https://go.cal.com/custom-attributes",
title: "Custom Attributes",
description: "Define roles and attributes for your teams",
href: "https://go.cal.com/custom-attributes",
},
{
id: 13,
thumbnailUrl: "https://img.youtube.com/vi/TTAASLLPKk0/0.jpg",
mediaLink: "https://go.cal.com/ooo-video",
title: "ooo.new",
description: "Easily go out-of-office",
href: "https://go.cal.com/ooo-video",
},
{
id: 12,
thumbnailUrl: "https://cal.com/og-image-cal-ai.jpg",
mediaLink: "https://go.cal.com/cal-ai",
title: "Cal.ai",
description: "Your personal AI scheduling assistant",
href: "https://go.cal.com/cal-ai",
},
{
id: 11,
thumbnailUrl: "https://img.youtube.com/vi/KTg_qzA9NEc/0.jpg",
mediaLink: "https://go.cal.com/insights",
title: "Insights",
description: "Get a better understanding of your business",
href: "https://go.cal.com/insights",
},
{
id: 10,
thumbnailUrl: "https://img.youtube.com/vi/jvaBafzVUQc/0.jpg",
mediaLink: "https://go.cal.com/video",
title: "Cal Video",
description: "Free video conferencing with recording",
href: "https://go.cal.com/video",
},
{
id: 9,
thumbnailUrl: "https://img.youtube.com/vi/93iOmzHieCU/0.jpg",
mediaLink: "https://go.cal.com/round-robin",
title: "Round-Robin",
description: "Create advanced group meetings with round-robin",
href: "https://go.cal.com/round-robin",
},
{
id: 8,
thumbnailUrl: "https://img.youtube.com/vi/piKlAiibAFo/0.jpg",
mediaLink: "https://go.cal.com/workflows",
title: "Automate Workflows",
description: "Make time work for you and automate tasks",
href: "https://go.cal.com/workflows",
},
{
id: 7,
thumbnailUrl: "https://img.youtube.com/vi/UVXgo12cY4g/0.jpg",
mediaLink: "https://go.cal.com/routing-forms",
title: "Routing Forms",
description: "Ask questions and route to the correct person",
href: "https://go.cal.com/routing-forms",
},
{
id: 6,
thumbnailUrl: "https://img.youtube.com/vi/yGiZo1Ry5-8/0.jpg",
mediaLink: "https://go.cal.com/recurring-video",
title: "Recurring Bookings",
description: "Learn how to create a recurring schedule",
href: "https://go.cal.com/recurring-video",
},
{
id: 5,
thumbnailUrl: "https://img.youtube.com/vi/0v_nQtpxC_4/0.jpg",
mediaLink: "https://go.cal.com/payments-video",
title: "Accept Payments",
description: "Charge for your time with Cal.com's Stripe App",
href: "https://app.cal.com/apps/stripe",
},
{
id: 4,
thumbnailUrl: "https://img.youtube.com/vi/zGr_s-fG84k/0.jpg",
mediaLink: "https://go.cal.com/confirmation-video",
title: "Requires Confirmation",
description: "Learn how to be in charge of your bookings",
href: "https://cal.com/resources/feature/opt-in",
},
{
id: 3,
thumbnailUrl: "https://img.youtube.com/vi/-L8MCiT6uhQ/0.jpg",
mediaLink: "https://youtu.be/-L8MCiT6uhQ?si=DRJzRNhN85hqAwBl",
title: "Routing Forms, Workflows",
description: "Ask screening questions of potential bookers to connect them with the right person",
href: "https://youtu.be/-L8MCiT6uhQ?si=DRJzRNhN85hqAwBl",
},
{
id: 2,
thumbnailUrl: "https://img.youtube.com/vi/EAc46SPL6iA/0.jpg",
mediaLink: "https://go.cal.com/teams-video",
title: "How to set up Teams",
description: "Learn how to use round-robin and collective events.",
href: "https://cal.com/teams",
},
{
id: 1,
thumbnailUrl: "https://img.youtube.com/vi/VZ5PfQzzxBw/0.jpg",
mediaLink: "https://youtu.be/VZ5PfQzzxBw?si=oB0LPSxG8dWIwg5a",
title: "Dynamic booking links",
description: "Booking link that allows people to quickly schedule meetings.",
href: "https://youtu.be/VZ5PfQzzxBw?si=oB0LPSxG8dWIwg5a",
},
];
const reversedTips = [...shuffle(tips).slice(0).reverse()];
const [list, setList] = useState<Tip[]>(() => {
if (typeof window === "undefined") {
@@ -230,8 +242,9 @@ function Tips() {
mediaLinkOnClick={
isTopTip
? () => {
posthog.capture("tip_video_clicked", tip);
}
posthog.capture("tip_video_clicked", tip);
if (tip.onClick) tip.onClick();
}
: undefined
}
title={t(tip.title)}
@@ -239,23 +252,24 @@ function Tips() {
learnMore={
isTopTip
? {
href: tip.href,
text: t("learn_more"),
onClick: () => {
posthog.capture("tip_learn_more_clicked", tip);
},
href: tip.href,
text: t("learn_more"),
onClick: () => {
posthog.capture("tip_learn_more_clicked", tip);
if (tip.onClick) tip.onClick();
}
}
: undefined
}
actionButton={
isTopTip
? {
onClick: () => {
posthog.capture("tip_dismiss_clicked", tip);
handleRemoveItem(tip.id);
},
child: t("dismiss"),
}
onClick: () => {
posthog.capture("tip_dismiss_clicked", tip);
handleRemoveItem(tip.id);
},
child: t("dismiss"),
}
: undefined
}
containerProps={{
+23 -12
View File
@@ -133,9 +133,9 @@ export interface BaseCardProps extends CVACardType {
"data-testid"?: string;
};
learnMore?: {
href: string;
href?: string;
text: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
};
mediaLink?: string;
mediaLinkOnClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
@@ -161,7 +161,7 @@ export function Card({
buttonClassName,
mediaLinkOnClick,
}: BaseCardProps) {
const LinkComponent = learnMore && learnMore.href.startsWith("https") ? "a" : Link;
const LinkComponent = learnMore?.href?.startsWith("https") ? "a" : Link;
return (
<div
className={classNames(
@@ -252,19 +252,30 @@ export function Card({
{(variant === "SidebarCard" || variant === "NewLaunchSidebarCard") && (
<div className="mt-2 flex items-center justify-between">
{learnMore && (
<LinkComponent
href={learnMore.href}
target="_blank"
rel="noreferrer"
onClick={learnMore.onClick}
className={classNames("text-default text-xs font-medium", buttonClassName)}>
{learnMore.text}
</LinkComponent>
<>
{learnMore.href ? (
<LinkComponent
href={learnMore.href}
target="_blank"
rel="noreferrer"
onClick={learnMore.onClick}
className={classNames("text-default text-xs font-medium cursor-pointer", buttonClassName)}>
{learnMore.text}
</LinkComponent>
) : learnMore.onClick ? (
<button type="button"
color="minimal"
onClick={learnMore.onClick}
className={classNames("cursor-pointer text-default text-xs font-medium", buttonClassName)}>
{learnMore.text}
</button>
) : undefined}
</>
)}
{actionButton?.child && (
<button
className={classNames(
"text-default hover:text-emphasis p-0 text-xs font-normal",
"text-default hover:text-emphasis p-0 text-xs font-normal cursor-pointer",
buttonClassName
)}
color="minimal"