diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout.tsx index c61478ce6f50..96a755d5e40a 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout.tsx @@ -1,3 +1,4 @@ +import { cookies } from "next/headers"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { MainNavigation } from "@/app/(app)/workspaces/[workspaceId]/components/MainNavigation"; import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/TopControlBar"; @@ -6,6 +7,8 @@ import { getPublicDomain } from "@/lib/getPublicUrl"; import { getAccessFlags } from "@/lib/membership/utils"; import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag"; import { getTranslate } from "@/lingodotdev/server"; +import { TrialEndingWarningModal } from "@/modules/ee/billing/components/trial-ending-warning-modal"; +import { TrialResponseWarningModal } from "@/modules/ee/billing/components/trial-response-warning-modal"; import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; @@ -16,6 +19,40 @@ interface WorkspaceLayoutProps { children?: React.ReactNode; } +type TCookieStore = Awaited>; + +// Response-limit warning for Hobby (free) plan: 200 = 80% of the 250/mo cap, 250 = limit reached. +const getResponseWarningThreshold = ( + responseCount: number, + cookieStore: TCookieStore +): "200" | "250" | null => { + if (responseCount >= 250 && !cookieStore.get("trial_warning_shown_250")) { + return "250"; + } + if ( + responseCount >= 200 && + !cookieStore.get("trial_warning_shown_200") && + !cookieStore.get("trial_warning_shown_250") + ) { + return "200"; + } + return null; +}; + +// Show the loss-aversion trial-ending modal once on each of the last 3 days of the trial. +const getTrialEndingDaysRemaining = (trialEnd: string | Date, cookieStore: TCookieStore): number | null => { + const MS_PER_DAY = 86_400_000; + const trialEndTime = new Date(trialEnd).getTime(); + if (!Number.isFinite(trialEndTime)) { + return null; + } + const daysRemaining = Math.ceil((trialEndTime - Date.now()) / MS_PER_DAY); + if (daysRemaining >= 1 && daysRemaining <= 3 && !cookieStore.get(`trial_ending_shown_${daysRemaining}`)) { + return daysRemaining; + } + return null; +}; + export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutProps) => { const t = await getTranslate(); const publicDomain = getPublicDomain(); @@ -37,8 +74,23 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP const { features, lastChecked, isPendingDowngrade, active, status } = license; const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; - const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id); - const newTrialBannerVariant = await getPostHogFeatureFlag(user.id, "a-b_navigation_rich-trial-banner-v2"); + const isTrialing = IS_FORMBRICKS_CLOUD && organization.billing?.stripe?.subscriptionStatus === "trialing"; + // Hobby (free) plan only — excludes trial and paid (Pro/Scale) orgs. + const isHobby = IS_FORMBRICKS_CLOUD && organization.billing?.stripe?.plan === "hobby"; + + const [ + organizationWorkspacesLimit, + newTrialBannerVariant, + responseWarningVariant, + trialEndingVariant, + cookieStore, + ] = await Promise.all([ + getOrganizationWorkspacesLimit(organization.id), + getPostHogFeatureFlag(user.id, "a-b_navigation_rich-trial-banner-v2"), + isHobby ? getPostHogFeatureFlag(user.id, "a-b_workspace_trial-response-warning") : Promise.resolve(null), + isTrialing ? getPostHogFeatureFlag(user.id, "a-b_workspace_trial-ending-warning") : Promise.resolve(null), + isTrialing || isHobby ? cookies() : Promise.resolve(null), + ]); const isOwnerOrManager = isOwner || isManager; // Validate that workspace permission exists for members @@ -46,9 +98,24 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP throw new ResourceNotFoundError(t("common.workspace"), null); } + // (Hobby response warning and trial-ending target mutually exclusive audiences, so no stacking.) + const responseWarningThreshold = + isHobby && responseWarningVariant === "test" && cookieStore + ? getResponseWarningThreshold(responseCount, cookieStore) + : null; + + const trialEnd = organization.billing?.stripe?.trialEnd; + const trialEndingDaysRemaining = + isTrialing && trialEndingVariant === "test" && cookieStore && trialEnd + ? getTrialEndingDaysRemaining(trialEnd, cookieStore) + : null; + + const billingHref = `/workspaces/${workspace.id}/settings/organization/billing`; + return (
- {IS_FORMBRICKS_CLOUD && ( + {/* Hide the limits-reached toast for Hobby users in the response-warning test variant — the modal replaces it with richer copy + CTAs. */} + {IS_FORMBRICKS_CLOUD && !isTrialing && !(isHobby && responseWarningVariant === "test") && ( )} @@ -61,6 +128,18 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP status={status} /> + {responseWarningThreshold && ( + + )} + + {trialEndingDaysRemaining && ( + + )} +
{ + posthog.capture("billing_pricing_cta_clicked", { + plan, + interval, + cta: getCtaKey(plan, interval), + }); + const actionKey = `${plan}-${interval}`; setIsPlanActionPending(actionKey); @@ -785,6 +792,37 @@ export const PricingTable = ({ } }; + const getCtaKey = (plan: TStandardPlan, interval: TCloudBillingInterval) => { + const isCurrentSelection = isCurrentPlanSelection( + plan, + interval, + currentCloudPlan, + currentBillingInterval + ); + + if (isCurrentSelection && isTrialingWithoutPayment) return "continue_with_plan_after_trial"; + if (isTrialingWithoutPayment && plan === "hobby") return "downgrade_to_hobby"; + if ( + canCancelCurrentPaidPlanAtPeriodEnd( + plan, + interval, + currentCloudPlan, + currentBillingInterval, + isTrialingWithoutPayment, + pendingChange + ) + ) + return "cancel_at_period_end"; + if (isCurrentSelection && pendingChange?.targetPlan === "hobby") return "pending_plan_cta"; + if (isCurrentSelection) return "current_plan_cta"; + const isPendingSelection = + pendingChange?.targetPlan === plan && (plan === "hobby" || pendingChange.targetInterval === interval); + if (isPendingSelection) return "pending_plan_cta"; + if (!hasPaymentMethod && plan !== "hobby") return "upgrade_now"; + if (currentPlanLevel === null) return "switch_plan_now"; + return STANDARD_PLAN_LEVEL[plan] > currentPlanLevel ? "upgrade_now" : "switch_at_period_end"; + }; + const getCtaLabel = (plan: TStandardPlan, interval: TCloudBillingInterval) => { const isCurrentSelection = isCurrentPlanSelection( plan, diff --git a/apps/web/modules/ee/billing/components/select-plan-card.tsx b/apps/web/modules/ee/billing/components/select-plan-card.tsx index 7396162b8969..ea0d09a062c0 100644 --- a/apps/web/modules/ee/billing/components/select-plan-card.tsx +++ b/apps/web/modules/ee/billing/components/select-plan-card.tsx @@ -1,9 +1,10 @@ "use client"; -import { CheckIcon, GiftIcon } from "lucide-react"; +import { CheckIcon, GiftIcon, XCircleIcon } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import posthog from "posthog-js"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import calLogo from "@/images/customer-logos/cal-logo-light.svg"; @@ -11,8 +12,17 @@ import ethereumLogo from "@/images/customer-logos/ethereum-logo.png"; import flixbusLogo from "@/images/customer-logos/flixbus-white.svg"; import githubLogo from "@/images/customer-logos/github-logo.png"; import siemensLogo from "@/images/customer-logos/siemens.png"; +import { getPostHogClientFeatureFlag } from "@/lib/posthog/client"; import { startHobbyAction, startProTrialAction } from "@/modules/ee/billing/actions"; import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; interface SelectPlanCardProps { nextUrl: string; @@ -38,6 +48,13 @@ export const SelectPlanCard = ({ const router = useRouter(); const [isStartingTrial, setIsStartingTrial] = useState(false); const [isStartingHobby, setIsStartingHobby] = useState(false); + const [showHobbyConfirm, setShowHobbyConfirm] = useState(false); + const [hobbyConfirmEnabled, setHobbyConfirmEnabled] = useState(false); + useEffect(() => { + return posthog.onFeatureFlags(() => { + setHobbyConfirmEnabled(getPostHogClientFeatureFlag("a-b_billing_hobby-downgrade-confirm") === "test"); + }); + }, []); const { t } = useTranslation(); let ctaCopy: string; if (ctaVariant === "variant_b") { @@ -159,11 +176,65 @@ export const SelectPlanCard = ({ + + + + + {t("workspace.settings.billing.hobby_confirm_title")} + + +

{t("workspace.settings.billing.hobby_confirm_description")}

+
    + {[ + { from: "2,000", to: t("workspace.settings.billing.hobby_confirm_feature_responses") }, + { from: "3", to: t("workspace.settings.billing.hobby_confirm_feature_workspaces") }, + ].map((item) => ( +
  • + + + {item.from}{" "} + {item.to} + +
  • + ))} + {[ + t("workspace.settings.billing.hobby_confirm_feature_branding"), + t("workspace.settings.billing.hobby_confirm_feature_contacts"), + t("workspace.settings.billing.hobby_confirm_feature_ai"), + ].map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ + + + +
+
); }; diff --git a/apps/web/modules/ee/billing/components/trial-ending-warning-modal.tsx b/apps/web/modules/ee/billing/components/trial-ending-warning-modal.tsx new file mode 100644 index 000000000000..cd52c9711e43 --- /dev/null +++ b/apps/web/modules/ee/billing/components/trial-ending-warning-modal.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { ClockIcon, XCircleIcon } from "lucide-react"; +import Link from "next/link"; +import posthog from "posthog-js"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/modules/ui/components/button"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog"; + +const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days — long enough to outlive the trial's final days + +interface TrialEndingWarningModalProps { + daysRemaining: number; + billingHref: string; +} + +export const TrialEndingWarningModal = ({ daysRemaining, billingHref }: TrialEndingWarningModalProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + useEffect(() => { + document.cookie = `trial_ending_shown_${daysRemaining}=true; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`; + setOpen(true); + posthog.capture("trial_ending_warning_shown", { days_remaining: daysRemaining }); + }, [daysRemaining]); + + const handleDismiss = () => { + setOpen(false); + }; + + const isLastDay = daysRemaining <= 1; + + return ( + { + if (!v) handleDismiss(); + }}> + +
+
+
+ +
+ +
+ + {t("workspace.settings.billing.trial_ending_title", { count: daysRemaining })} + + + {t("workspace.settings.billing.trial_ending_description")} + +
+
+ +
    + {[ + { from: "2,000", to: t("workspace.settings.billing.hobby_confirm_feature_responses") }, + { from: "3", to: t("workspace.settings.billing.hobby_confirm_feature_workspaces") }, + ].map((item) => ( +
  • + + + {item.from}{" "} + {item.to} + +
  • + ))} + {[ + t("workspace.settings.billing.hobby_confirm_feature_branding"), + t("workspace.settings.billing.hobby_confirm_feature_contacts"), + t("workspace.settings.billing.hobby_confirm_feature_ai"), + ].map((item) => ( +
  • + + {item} +
  • + ))} +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/modules/ee/billing/components/trial-response-warning-modal.tsx b/apps/web/modules/ee/billing/components/trial-response-warning-modal.tsx new file mode 100644 index 000000000000..7a59ab8bbf18 --- /dev/null +++ b/apps/web/modules/ee/billing/components/trial-response-warning-modal.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { InfoIcon } from "lucide-react"; +import Link from "next/link"; +import posthog from "posthog-js"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/modules/ui/components/button"; +import { Dialog, DialogContent } from "@/modules/ui/components/dialog"; + +const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days + +interface TrialResponseWarningModalProps { + threshold: "200" | "250"; + billingHref: string; + responseCount: number; +} + +export const TrialResponseWarningModal = ({ + threshold, + billingHref, + responseCount, +}: TrialResponseWarningModalProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + useEffect(() => { + document.cookie = `trial_warning_shown_${threshold}=true; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`; + setOpen(true); + posthog.capture("trial_response_warning_shown", { threshold, response_count: responseCount }); + }, [threshold, responseCount]); + + const handleDismiss = () => { + setOpen(false); + }; + + const isLimitReached = threshold === "250"; + + return ( + { + if (!v) handleDismiss(); + }}> + +
+
+
+ +
+ +
+

+ {isLimitReached + ? t("workspace.settings.billing.trial_warning_250_title") + : t("workspace.settings.billing.trial_warning_200_title")} +

+

+ {isLimitReached + ? t("workspace.settings.billing.trial_warning_250_description") + : t("workspace.settings.billing.trial_warning_200_description")} +

+
+
+ +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/modules/survey/components/template-list/lib/utils.test.ts b/apps/web/modules/survey/components/template-list/lib/utils.test.ts index bf8ebfc41fb6..014fbf1ea16d 100644 --- a/apps/web/modules/survey/components/template-list/lib/utils.test.ts +++ b/apps/web/modules/survey/components/template-list/lib/utils.test.ts @@ -90,10 +90,10 @@ describe("Template utils", () => { expect(result).toEqual([ { value: "productManager", label: "common.product_manager" }, - { value: "customerSuccess", label: "common.customer_success" }, { value: "marketing", label: "common.marketing" }, - { value: "sales", label: "common.sales" }, + { value: "customerSuccess", label: "common.customer_success" }, { value: "peopleManager", label: "common.people_manager" }, + { value: "sales", label: "common.sales" }, ]); expect(mockT).toHaveBeenCalledWith("common.product_manager"); expect(mockT).toHaveBeenCalledWith("common.customer_success"); diff --git a/apps/web/modules/survey/components/template-list/lib/utils.ts b/apps/web/modules/survey/components/template-list/lib/utils.ts index cea76f0cd18e..309d3f11ed47 100644 --- a/apps/web/modules/survey/components/template-list/lib/utils.ts +++ b/apps/web/modules/survey/components/template-list/lib/utils.ts @@ -16,8 +16,8 @@ export const getIndustryMapping = (t: TFunction): { value: TWorkspaceConfigIndus export const getRoleMapping = (t: TFunction): { value: TTemplateRole; label: string }[] => [ { value: "productManager", label: t("common.product_manager") }, - { value: "customerSuccess", label: t("common.customer_success") }, { value: "marketing", label: t("common.marketing") }, - { value: "sales", label: t("common.sales") }, + { value: "customerSuccess", label: t("common.customer_success") }, { value: "peopleManager", label: t("common.people_manager") }, + { value: "sales", label: t("common.sales") }, ]; diff --git a/apps/web/modules/survey/list/components/featured-templates.tsx b/apps/web/modules/survey/list/components/featured-templates.tsx new file mode 100644 index 000000000000..84fa5e77b8f1 --- /dev/null +++ b/apps/web/modules/survey/list/components/featured-templates.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { + BarChart2Icon, + CheckIcon, + ChevronDownIcon, + EyeOffIcon, + HeartHandshakeIcon, + LayoutTemplateIcon, + LogOutIcon, + MegaphoneIcon, + SmileIcon, + SparklesIcon, + ThumbsUpIcon, + TrendingUpIcon, + UsersIcon, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import posthog from "posthog-js"; +import { useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import type { Workspace } from "@formbricks/database/prisma-browser"; +import type { TSurveyType } from "@formbricks/types/surveys/types"; +import type { TTemplateRole } from "@formbricks/types/templates"; +import type { TUserLocale } from "@formbricks/types/user"; +import { templates } from "@/app/lib/templates"; +import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client"; +import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; +import { CreateWithAIDialog } from "@/modules/survey/components/template-list/components/create-with-ai-dialog"; +import { useCreateSurveyFromTemplate } from "@/modules/survey/components/template-list/hooks/use-create-survey-from-template"; +import { getRoleMapping } from "@/modules/survey/components/template-list/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; +import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; + +const HIDDEN_COOKIE = "featured_templates_hidden"; +const HIDDEN_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year + +const ROLE_ICONS: Record = { + productManager: BarChart2Icon, + customerSuccess: HeartHandshakeIcon, + marketing: MegaphoneIcon, + sales: TrendingUpIcon, + peopleManager: UsersIcon, +}; + +const ROLE_COLORS: Record = { + productManager: "text-blue-500", + customerSuccess: "text-violet-500", + marketing: "text-orange-500", + sales: "text-emerald-500", + peopleManager: "text-pink-500", +}; + +type TDefaultTemplate = { id: string; icon: LucideIcon; title: string; description: string }; + +interface FeaturedTemplatesProps { + workspace: Workspace; + locale: TUserLocale; + isAIAvailable: boolean; + aiUnavailableReason?: TAIUnavailableReason; +} + +export const FeaturedTemplates = ({ + workspace, + locale, + isAIAvailable, + aiUnavailableReason, +}: Readonly) => { + const { t } = useTranslation(); + + const defaultTemplates: TDefaultTemplate[] = [ + { + id: "nps", + icon: SmileIcon, + title: t("workspace.surveys.featured_templates.nps_title"), + description: t("workspace.surveys.featured_templates.nps_description"), + }, + { + id: "csat", + icon: ThumbsUpIcon, + title: t("workspace.surveys.featured_templates.csat_title"), + description: t("workspace.surveys.featured_templates.csat_description"), + }, + { + id: "churn-survey", + icon: LogOutIcon, + title: t("workspace.surveys.featured_templates.churn_title"), + description: t("workspace.surveys.featured_templates.churn_description"), + }, + ]; + + const router = useRouter(); + const createSurveyMutation = useCreateSurveyFromTemplate(); + const [loadingId, setLoadingId] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + const [isAIDialogOpen, setIsAIDialogOpen] = useState(false); + const [isHidden, setIsHidden] = useState(false); + + useEffect(() => { + if (document.cookie.split("; ").includes(`${HIDDEN_COOKIE}=true`)) { + setIsHidden(true); + } + }, []); + + const handleHide = () => { + document.cookie = `${HIDDEN_COOKIE}=true; path=/; max-age=${HIDDEN_COOKIE_MAX_AGE}; SameSite=Lax`; + posthog.capture("featured_templates_hidden"); + setIsHidden(true); + }; + + const roleMapping = useMemo(() => getRoleMapping(t), [t]); + + const roleFilteredTemplates = useMemo(() => { + if (!selectedRole) return null; + return templates(t) + .filter((tmpl) => tmpl.role === selectedRole) + .slice(0, 4); + }, [selectedRole, t]); + + const surveyType: TSurveyType = + workspace.config.channel === "website" ? "app" : (workspace.config.channel ?? "link"); + + const handleUse = async (templateId: string) => { + posthog.capture("featured_template_used", { + template_id: templateId, + role_filter: selectedRole, + survey_type: surveyType, + source: selectedRole ? "role_filtered" : "default", + }); + setLoadingId(templateId); + try { + const survey = await createSurveyMutation.mutateAsync({ + workspaceId: workspace.id, + templateId, + source: "catalog", + surveyType, + defaultLanguage: locale, + }); + router.push(`/workspaces/${workspace.id}/surveys/${survey.id}/edit`); + } catch (error) { + toast.error(getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))); + } finally { + setLoadingId(null); + } + }; + + const selectedRoleLabel = selectedRole + ? roleMapping.find((r) => r.value === selectedRole)?.label + : t("common.all_roles"); + + const browseAllHref = selectedRole + ? `/workspaces/${workspace.id}/surveys/templates?role=${selectedRole}` + : `/workspaces/${workspace.id}/surveys/templates`; + + const cardClass = + "relative flex flex-col items-center gap-3 rounded-xl border border-slate-200 bg-white p-5 text-center shadow-sm transition-transform duration-150 hover:scale-[1.02] hover:border-slate-300 disabled:opacity-60"; + + if (isHidden) return null; + + return ( +
+
+
+ + {t("workspace.surveys.featured_templates.templates_for")} + + + + + + + setSelectedRole(null)}> + + {t("common.all_roles")} + {selectedRole === null && } + + + {roleMapping.map((role) => { + const RoleIcon = ROLE_ICONS[role.value]; + const roleColor = ROLE_COLORS[role.value]; + return ( + setSelectedRole(role.value)}> + + + + {role.label} + + {selectedRole === role.value && } + + + ); + })} + + +
+ +
+ +
+ {!selectedRole && ( + + )} + + {roleFilteredTemplates + ? roleFilteredTemplates.map((tmpl) => { + const RoleIcon = ROLE_ICONS[tmpl.role as TTemplateRole] ?? BarChart2Icon; + const roleColor = ROLE_COLORS[tmpl.role as TTemplateRole] ?? "text-slate-400"; + const isLoading = loadingId === tmpl.id; + return ( + + ); + }) + : defaultTemplates.map(({ id, icon: Icon, title, description }) => { + const isLoading = loadingId === id; + return ( + + ); + })} + + +
+ + +
+ ); +}; diff --git a/apps/web/modules/survey/list/components/survey-list.tsx b/apps/web/modules/survey/list/components/survey-list.tsx index 69b118224450..d3433ca8fce4 100644 --- a/apps/web/modules/survey/list/components/survey-list.tsx +++ b/apps/web/modules/survey/list/components/survey-list.tsx @@ -36,6 +36,7 @@ import { import { EmptyState } from "@/modules/ui/components/empty-state"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; +import { FeaturedTemplates } from "./featured-templates"; import { SurveyCard } from "./survey-card"; import { SurveyFilters } from "./survey-filters"; import { SurveyLoading } from "./survey-loading"; @@ -49,6 +50,7 @@ interface SurveysListProps { locale: TUserLocale; isAIAvailable: boolean; aiUnavailableReason?: TAIUnavailableReason; + showFeaturedTemplates?: boolean; } type NewSurveyMenuProps = { @@ -145,7 +147,8 @@ export const SurveysList = ({ locale, isAIAvailable, aiUnavailableReason, -}: SurveysListProps) => { + showFeaturedTemplates = false, +}: Readonly) => { const { t } = useTranslation(); const [surveyFilters, setSurveyFilters] = useState(initialFilters); const [isFilterInitialized, setIsFilterInitialized] = useState(false); @@ -334,6 +337,14 @@ export const SurveysList = ({ : createSurveyButton} />
+ {!isReadOnly && showFeaturedTemplates && ( + + )} l ?? DEFAULT_LOCALE), + getPostHogFeatureFlag(session.user.id, "a-b_surveys_featured-templates-create-with-ai"), + getSurveyAIAvailability(workspace.organizationId, { isReadOnly }), + ]); const workspaceWithRequiredProps = { ...workspace, brandColor: workspace.styling?.brandColor?.light ?? null, @@ -59,6 +61,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps) locale={locale} isAIAvailable={isAIAvailable} aiUnavailableReason={aiUnavailableReason} + showFeaturedTemplates={featuredTemplatesVariant === "test"} /> ); }; diff --git a/apps/web/modules/workspaces/settings/(setup)/app-connection/components/setup-instructions.tsx b/apps/web/modules/workspaces/settings/(setup)/app-connection/components/setup-instructions.tsx new file mode 100644 index 000000000000..6b792369c233 --- /dev/null +++ b/apps/web/modules/workspaces/settings/(setup)/app-connection/components/setup-instructions.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { CodeIcon, SparklesIcon } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { CodeBlock } from "@/modules/ui/components/code-block"; +import { OptionsSwitch } from "@/modules/ui/components/options-switch"; + +interface SetupInstructionsProps { + htmlSnippet: string; + aiPrompt: string; +} + +export const SetupInstructions = ({ htmlSnippet, aiPrompt }: Readonly) => { + const { t } = useTranslation(); + const [selectedOption, setSelectedOption] = useState<"ai" | "code">("ai"); + + const options = [ + { + value: "ai", + label: t("workspace.app-connection.connect_with_ai"), + icon: , + }, + { + value: "code", + label: t("workspace.app-connection.view_code_snippet"), + icon: , + }, + ]; + + return ( +
+ setSelectedOption(value as "ai" | "code")} + /> + {selectedOption === "ai" ? ( + + {aiPrompt} + + ) : ( + + {htmlSnippet} + + )} +
+ ); +}; diff --git a/apps/web/modules/workspaces/settings/(setup)/app-connection/page.tsx b/apps/web/modules/workspaces/settings/(setup)/app-connection/page.tsx index ff496c67a179..9751c909e34c 100644 --- a/apps/web/modules/workspaces/settings/(setup)/app-connection/page.tsx +++ b/apps/web/modules/workspaces/settings/(setup)/app-connection/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { WidgetStatusIndicator } from "@/app/(app)/workspaces/[workspaceId]/components/WidgetStatusIndicator"; import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard"; import { WEBAPP_URL } from "@/lib/constants"; +import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag"; import { getTranslate } from "@/lingodotdev/server"; import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { CodeBlock } from "@/modules/ui/components/code-block"; @@ -11,6 +12,7 @@ import { IdBadge } from "@/modules/ui/components/id-badge"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; +import { SetupInstructions } from "./components/setup-instructions"; export const AppConnectionPage = async ({ params }: { params: Promise<{ workspaceId: string }> }) => { const t = await getTranslate(); @@ -19,7 +21,155 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ workspac const workspaceIdMigrationUrl = "https://formbricks.com/docs/surveys/website-app-surveys/workspace-id-migration"; - const { workspace } = await getWorkspaceAuth(workspaceId); + const { workspace, session } = await getWorkspaceAuth(workspaceId); + const showAIPrompt = session?.user.id + ? (await getPostHogFeatureFlag(session.user.id, "a-b_app-connection_ai-prompt")) === "test" + : false; + + const aiPrompt = `Integrate Formbricks into my app. + +Detect my framework from the project files and follow the matching instructions below. + +Workspace ID : ${workspace.id} +App URL : ${WEBAPP_URL} + +--- + +## HTML (no framework) +Paste this snippet into your on every page: + + + + + +## React.js +1. Install: npm install @formbricks/js zod +2. In src/App.js (or App.tsx), add at the top level: + + import formbricks from "@formbricks/js"; + if (typeof window !== "undefined") { + formbricks.setup({ workspaceId: "${workspace.id}", appUrl: "${WEBAPP_URL}" }); + } + +## Next.js — App Router +1. Install: npm install @formbricks/js zod +2. Create app/formbricks.tsx: + + "use client"; + import { usePathname, useSearchParams } from "next/navigation"; + import { useEffect } from "react"; + import formbricks from "@formbricks/js"; + + export default function FormbricksProvider() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + useEffect(() => { + formbricks.setup({ workspaceId: "${workspace.id}", appUrl: "${WEBAPP_URL}" }); + }, []); + useEffect(() => { formbricks?.registerRouteChange(); }, [pathname, searchParams]); + return null; + } + +3. In app/layout.tsx, add inside : + + import { Suspense } from "react"; + import FormbricksProvider from "./formbricks"; + // ... + + +## Next.js — Pages Router +1. Install: npm install @formbricks/js zod +2. In src/pages/_app.tsx: + + import { useRouter } from "next/router"; + import { useEffect } from "react"; + import formbricks from "@formbricks/js"; + + if (typeof window !== "undefined") { + formbricks.setup({ workspaceId: "${workspace.id}", appUrl: "${WEBAPP_URL}" }); + } + export default function App({ Component, pageProps }) { + const router = useRouter(); + useEffect(() => { + const handleRouteChange = formbricks?.registerRouteChange; + router.events.on("routeChangeComplete", handleRouteChange); + return () => router.events.off("routeChangeComplete", handleRouteChange); + }, []); + return ; + } + +## Vue.js +1. Install: npm install @formbricks/js +2. Create src/formbricks.js: + + import formbricks from "@formbricks/js"; + if (typeof window !== "undefined") { + formbricks.setup({ workspaceId: "${workspace.id}", appUrl: "${WEBAPP_URL}" }); + } + export default formbricks; + +3. In src/main.js, import formbricks and add: + + router.afterEach(() => { + if (typeof formbricks !== "undefined") formbricks.registerRouteChange(); + }); + +## React Native +1. Install: npm install @formbricks/react-native +2. In App.js/App.tsx: + + import Formbricks from "@formbricks/react-native"; + const config = { workspaceId: "${workspace.id}", appUrl: "${WEBAPP_URL}" }; + // Render inside your root component. + +## Flutter +1. Add to pubspec.yaml: formbricks (run flutter pub add formbricks) +2. Mount the widget high in your widget tree: + + Formbricks(appUrl: "${WEBAPP_URL}", workspaceId: "${workspace.id}") + +3. Drive it via static API: await Formbricks.track("event"), Formbricks.setUserId("uid"), etc. + +## iOS (Swift) +1. Add via Swift Package Manager: https://github.com/formbricks/ios.git +2. Initialize on app launch: + + import FormbricksSDK + let config = FormbricksConfig.Builder(appUrl: "${WEBAPP_URL}", workspaceId: "${workspace.id}").build() + Formbricks.setup(with: config) + Formbricks.setUserId("your-user-id") + +## Android (Kotlin) +1. Add to build.gradle.kts: + implementation("com.formbricks:android:1.0.0") + Also enable dataBinding = true under android.buildFeatures. +2. Initialize in your Activity: + + val config = FormbricksConfig.Builder("${WEBAPP_URL}", "${workspace.id}") + .setFragmentManager(supportFragmentManager).build() + Formbricks.setup(this, config) + Formbricks.setUserId("your-user-id") + +--- + +## After setup — identify users + +Call setUserId with the authenticated user's ID. Call logout() on sign-out. + + // Web + formbricks.setUserId("your-user-id"); + formbricks.logout(); + +## Validate + +Go to Settings → Connect your App — the widget indicator should turn green. +To debug, add ?formbricksDebug=true to your app URL and check the browser console.`; + const htmlSnippet = `