diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5ec2fa7ced..301aed0751 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,8 +12,8 @@ // Configure properties specific to VS Code. "vscode": { // Add the IDs of extensions you want installed when the container is created. - "extensions": ["dbaeumer.vscode-eslint"] - } + "extensions": ["dbaeumer.vscode-eslint"], + }, }, // Use 'forwardPorts' to make a list of ports inside the container available locally. @@ -25,5 +25,5 @@ "postAttachCommand": "pnpm dev --filter=web... --filter=demo...", // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "node" + "remoteUser": "node", } diff --git a/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx b/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx index c97313981e..9c37b29742 100644 --- a/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx +++ b/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx @@ -52,7 +52,7 @@ All you need to do is copy a ` +`; + + return ( +
+
+ +
+
+ {activeTab === "npm" ? ( +
+ + npm install @formbricks/js --save + +

+ Import Formbricks and initialize the widget in your Component (e.g. App.tsx): +

+ {`import formbricks from "@formbricks/js"; + +if (typeof window !== "undefined") { + formbricks.init({ + environmentId: "${environmentId}", + apiHost: "${webAppUrl}", + }); +}`} + +
+ ) : activeTab === "html" ? ( +
+

+ Insert this code into the <head> tag of your website: +

+ + {htmlSnippet} + +
+ + +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/app/(app)/onboarding/components/inapp/SurveyObjective.tsx b/apps/web/app/(app)/onboarding/components/inapp/SurveyObjective.tsx new file mode 100644 index 0000000000..4ce13c8b7c --- /dev/null +++ b/apps/web/app/(app)/onboarding/components/inapp/SurveyObjective.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { updateUserAction } from "@/app/(app)/onboarding/actions"; +import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle"; +import { handleTabNavigation } from "@/app/(app)/onboarding/utils"; +import { formbricksEnabled, updateResponse } from "@/app/lib/formbricks"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; + +import { cn } from "@formbricks/lib/cn"; +import { env } from "@formbricks/lib/env"; +import { TUser, TUserObjective } from "@formbricks/types/user"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; + +type ObjectiveProps = { + formbricksResponseId?: string; + user: TUser; + setCurrentStep: (currentStep: number) => void; +}; + +type ObjectiveChoice = { + label: string; + id: TUserObjective; +}; + +export const Objective: React.FC = ({ formbricksResponseId, user, setCurrentStep }) => { + const objectives: Array = [ + { label: "Increase conversion", id: "increase_conversion" }, + { label: "Improve user retention", id: "improve_user_retention" }, + { label: "Increase user adoption", id: "increase_user_adoption" }, + { label: "Sharpen marketing messaging", id: "sharpen_marketing_messaging" }, + { label: "Support sales", id: "support_sales" }, + { label: "Other", id: "other" }, + ]; + + const [selectedChoice, setSelectedChoice] = useState(null); + const [isProfileUpdating, setIsProfileUpdating] = useState(false); + const [otherValue, setOtherValue] = useState(""); + + const fieldsetRef = useRef(null); + + useEffect(() => { + const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [fieldsetRef, setSelectedChoice]); + + const next = () => { + setCurrentStep(4); + localStorage.setItem("onboardingCurrentStep", "4"); + }; + + const handleNextClick = async () => { + if (selectedChoice === "Other" && otherValue.trim() === "") { + toast.error("Other value missing"); + return; + } + if (selectedChoice) { + const selectedObjective = objectives.find((objective) => objective.label === selectedChoice); + if (selectedObjective) { + try { + setIsProfileUpdating(true); + await updateUserAction({ + objective: selectedObjective.id, + name: user.name ?? undefined, + }); + setIsProfileUpdating(false); + } catch (e) { + setIsProfileUpdating(false); + console.error(e); + toast.error("An error occured saving your settings"); + } + if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID && formbricksResponseId) { + const res = await updateResponse( + formbricksResponseId, + { + objective: selectedObjective.id === "other" ? otherValue : selectedObjective.label, + }, + true + ); + if (!res.ok) { + console.error("Error updating response", res.error); + } + } + next(); + } + } + }; + + return ( +
+ +
+ Choices +
+ {objectives.map((choice) => ( + + ))} +
+
+ +
+ + +
+
+ ); +}; diff --git a/apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx b/apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx new file mode 100644 index 0000000000..f7077cba4e --- /dev/null +++ b/apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { updateUserAction } from "@/app/(app)/onboarding/actions"; +import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle"; +import { handleTabNavigation } from "@/app/(app)/onboarding/utils"; +import { createResponse, formbricksEnabled } from "@/app/lib/formbricks"; +import { Session } from "next-auth"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; + +import { cn } from "@formbricks/lib/cn"; +import { env } from "@formbricks/lib/env"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; + +type RoleProps = { + setFormbricksResponseId: (id: string) => void; + session: Session; + setCurrentStep: (currentStep: number) => void; +}; + +type RoleChoice = { + label: string; + id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other"; +}; + +export const Role: React.FC = ({ setFormbricksResponseId, session, setCurrentStep }) => { + const [selectedChoice, setSelectedChoice] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); + const fieldsetRef = useRef(null); + const [otherValue, setOtherValue] = useState(""); + + useEffect(() => { + const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [fieldsetRef, setSelectedChoice]); + + const roles: Array = [ + { label: "Project Manager", id: "project_manager" }, + { label: "Engineer", id: "engineer" }, + { label: "Founder", id: "founder" }, + { label: "Marketing Specialist", id: "marketing_specialist" }, + { label: "Other", id: "other" }, + ]; + + const next = () => { + setCurrentStep(3); + localStorage.setItem("onboardingCurrentStep", "3"); + }; + + const handleNextClick = async () => { + if (selectedChoice === "Other" && otherValue.trim() === "") { + toast.error("Other value missing"); + return; + } + if (selectedChoice) { + const selectedRole = roles.find((role) => role.label === selectedChoice); + if (selectedRole) { + try { + setIsUpdating(true); + await updateUserAction({ + role: selectedRole.id, + }); + setIsUpdating(false); + } catch (e) { + setIsUpdating(false); + toast.error("An error occured saving your settings"); + console.error(e); + } + if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) { + const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID, session.user.id, { + role: selectedRole.id === "other" ? otherValue : selectedRole.label, + }); + if (res.ok) { + const response = res.data; + setFormbricksResponseId(response.id); + } else { + console.error("Error sending response to Formbricks", res.error); + } + } + next(); + } + } + }; + + return ( +
+ +
+ Choices +
+ {roles.map((choice) => ( + + ))} +
+
+
+ + +
+
+ ); +}; diff --git a/apps/web/app/(app)/onboarding/components/link/CreateFirstSurvey.tsx b/apps/web/app/(app)/onboarding/components/link/CreateFirstSurvey.tsx new file mode 100644 index 0000000000..cc4cd8f2ed --- /dev/null +++ b/apps/web/app/(app)/onboarding/components/link/CreateFirstSurvey.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { + customSurvey, + templates, +} from "@/app/(app)/environments/[environmentId]/surveys/templates/templates"; +import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle"; +import ChurnImage from "@/images/onboarding-churn.png"; +import FeedbackImage from "@/images/onboarding-collect-feedback.png"; +import NPSImage from "@/images/onboarding-nps.png"; +import { ArrowRight } from "lucide-react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "react-hot-toast"; + +import { TTemplate } from "@formbricks/types/templates"; +import { Button } from "@formbricks/ui/Button"; +import { OptionCard } from "@formbricks/ui/OptionCard"; + +import { createSurveyFromTemplate, finishOnboardingAction } from "../../actions"; + +interface CreateFirstSurveyProps { + environmentId: string; +} + +export function CreateFirstSurvey({ environmentId }: CreateFirstSurveyProps) { + const router = useRouter(); + const [loadingTemplate, setLoadingTemplate] = useState(null); + const templateOrder = ["Collect Feedback", "Net Promoter Score (NPS)", "Churn Survey"]; + const templateImages = { + "Collect Feedback": FeedbackImage, + "Net Promoter Score (NPS)": NPSImage, + "Churn Survey": ChurnImage, + }; + + const filteredTemplates = templates + .filter((template) => templateOrder.includes(template.name)) + .sort((a, b) => templateOrder.indexOf(a.name) - templateOrder.indexOf(b.name)); + + const newSurveyFromTemplate = async (template: TTemplate) => { + setLoadingTemplate(template.name); + if (typeof localStorage !== undefined) { + localStorage.removeItem("onboardingPathway"); + localStorage.removeItem("onboardingCurrentStep"); + } + await finishOnboardingAction(); + try { + const survey = await createSurveyFromTemplate(template, environmentId); + router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`); + } catch (e) { + toast.error("An error occurred creating a new survey"); + } + }; + + return ( +
+ +
+ {filteredTemplates.map((template) => { + const TemplateImage = templateImages[template.name]; + return ( + newSurveyFromTemplate(template)} + loading={loadingTemplate === template.name}> + {template.name} + + ); + })} +
+ +
+ ); +} diff --git a/apps/web/app/(app)/onboarding/components/onboarding.tsx b/apps/web/app/(app)/onboarding/components/onboarding.tsx new file mode 100644 index 0000000000..72673f04be --- /dev/null +++ b/apps/web/app/(app)/onboarding/components/onboarding.tsx @@ -0,0 +1,166 @@ +"use client"; + +import jsPackageJson from "@/../../packages/js/package.json"; +import { ConnectWithFormbricks } from "@/app/(app)/onboarding/components/inapp/ConnectWithFormbricks"; +import { InviteTeamMate } from "@/app/(app)/onboarding/components/inapp/InviteTeamMate"; +import { Objective } from "@/app/(app)/onboarding/components/inapp/SurveyObjective"; +import { Role } from "@/app/(app)/onboarding/components/inapp/SurveyRole"; +import { CreateFirstSurvey } from "@/app/(app)/onboarding/components/link/CreateFirstSurvey"; +import { Session } from "next-auth"; +import { useEffect, useState } from "react"; + +import { TEnvironment } from "@formbricks/types/environment"; +import { TTeam } from "@formbricks/types/teams"; +import { TUser } from "@formbricks/types/user"; + +import PathwaySelect from "./PathwaySelect"; +import { OnboardingHeader } from "./ProgressBar"; + +interface OnboardingProps { + isFormbricksCloud: boolean; + session: Session; + environment: TEnvironment; + user: TUser; + team: TTeam; + webAppUrl: string; +} + +export function Onboarding({ + isFormbricksCloud, + session, + environment, + user, + team, + webAppUrl, +}: OnboardingProps) { + const [selectedPathway, setSelectedPathway] = useState(null); + const [progress, setProgress] = useState(16); + const [formbricksResponseId, setFormbricksResponseId] = useState(); + const [currentStep, setCurrentStep] = useState(null); + const [iframeLoaded, setIframeLoaded] = useState(false); + const [iframeVisible, setIframeVisible] = useState(false); + const [fade, setFade] = useState(false); + + useEffect(() => { + if (currentStep === 2 && selectedPathway === "link") { + setIframeVisible(true); + } else { + setIframeVisible(false); + } + }, [currentStep, iframeLoaded, selectedPathway]); + + useEffect(() => { + if (iframeVisible) { + setFade(true); + + const handleSurveyCompletion = () => { + setFade(false); + + setTimeout(() => { + setIframeVisible(false); // Hide the iframe after fade-out effect is complete + setCurrentStep(5); // Assuming you want to move to the next step after survey completion + }, 1000); // Adjust timeout duration based on your fade-out CSS transition + }; + + window.addEventListener("formbricksSurveyCompleted", handleSurveyCompletion); + + // Cleanup function to remove the event listener + return () => { + window.removeEventListener("formbricksSurveyCompleted", handleSurveyCompletion); + }; + } + }, [iframeVisible, currentStep]); // Depend on iframeVisible and currentStep to re-evaluate when needed + + useEffect(() => { + if (typeof window !== "undefined") { + // Access localStorage only when window is available + const pathwayValueFromLocalStorage = localStorage.getItem("onboardingPathway"); + const currentStepValueFromLocalStorage = parseInt(localStorage.getItem("onboardingCurrentStep") ?? "1"); + + setSelectedPathway(pathwayValueFromLocalStorage); + setCurrentStep(currentStepValueFromLocalStorage); + } + }, []); + + useEffect(() => { + if (currentStep) { + const stepProgressMap = { 1: 16, 2: 50, 3: 65, 4: 75, 5: 90 }; + const newProgress = stepProgressMap[currentStep] || 16; + setProgress(newProgress); + localStorage.setItem("onboardingCurrentStep", currentStep.toString()); + } + }, [currentStep]); + + // Function to render current onboarding step + const renderOnboardingStep = () => { + switch (currentStep) { + case 1: + return ( + + ); + case 2: + return ( + selectedPathway !== "link" && ( + + ) + ); + case 3: + return ( + + ); + case 4: + return ( + + ); + case 5: + return selectedPathway === "link" ? ( + + ) : ( + + ); + default: + return null; + } + }; + + return ( +
+ +
+ {renderOnboardingStep()} + {iframeVisible && isFormbricksCloud && ( + + )} +
+
+ ); +} diff --git a/apps/web/app/(app)/onboarding/layout.tsx b/apps/web/app/(app)/onboarding/layout.tsx new file mode 100644 index 0000000000..f967ef2400 --- /dev/null +++ b/apps/web/app/(app)/onboarding/layout.tsx @@ -0,0 +1,19 @@ +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; + +import { authOptions } from "@formbricks/lib/authOptions"; +import ToasterClient from "@formbricks/ui/ToasterClient"; + +export default async function EnvironmentLayout({ children }) { + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return redirect(`/auth/login`); + } + + return ( +
+ + {children} +
+ ); +} diff --git a/apps/web/app/(app)/onboarding/loading.tsx b/apps/web/app/(app)/onboarding/loading.tsx deleted file mode 100644 index e1f44d88f3..0000000000 --- a/apps/web/app/(app)/onboarding/loading.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export default function Loading() { - return ( -
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 47b56013ce..c343b428ca 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -1,31 +1,44 @@ +import { Onboarding } from "@/app/(app)/onboarding/components/onboarding"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { authOptions } from "@formbricks/lib/authOptions"; +import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants"; import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service"; -import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { getUser } from "@formbricks/lib/user/service"; -import Onboarding from "./components/Onboarding"; - export default async function OnboardingPage() { const session = await getServerSession(authOptions); + + // Redirect to login if not authenticated if (!session) { - redirect("/auth/login"); + return redirect("/auth/login"); } - const userId = session?.user.id; + + // Redirect to home if onboarding is completed + if (session.user.onboardingCompleted) { + return redirect("/"); + } + + const userId = session.user.id; const environment = await getFirstEnvironmentByUserId(userId); - - if (!environment) { - throw new Error("No environment found for user"); - } - const user = await getUser(userId); - const product = await getProductByEnvironmentId(environment?.id!); + const team = environment ? await getTeamByEnvironmentId(environment.id) : null; - if (!environment || !user || !product) { - throw new Error("Failed to get environment, user, or product"); + // Ensure all necessary data is available + if (!environment || !user || !team) { + throw new Error("Failed to get necessary user, environment, or team information"); } - return ; + return ( + + ); } diff --git a/apps/web/app/(app)/onboarding/utils.ts b/apps/web/app/(app)/onboarding/utils.ts index ef29344037..20ed0b1dfe 100644 --- a/apps/web/app/(app)/onboarding/utils.ts +++ b/apps/web/app/(app)/onboarding/utils.ts @@ -1,4 +1,3 @@ -// util.js export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => { if (event.key !== "Tab") { return; diff --git a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts index 7e3aaa194a..0146050d30 100644 --- a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts @@ -1,13 +1,18 @@ +import { getFirstSurvey } from "@/app/(app)/environments/[environmentId]/surveys/templates/templates"; import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/in-app/sync/lib/posthog"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { NextRequest } from "next/server"; import { getActionClasses } from "@formbricks/lib/actionClass/service"; -import { IS_FORMBRICKS_CLOUD, PRICING_APPSURVEYS_FREE_RESPONSES } from "@formbricks/lib/constants"; +import { + IS_FORMBRICKS_CLOUD, + PRICING_APPSURVEYS_FREE_RESPONSES, + WEBAPP_URL, +} from "@formbricks/lib/constants"; import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { getSurveys } from "@formbricks/lib/survey/service"; +import { createSurvey, getSurveys } from "@formbricks/lib/survey/service"; import { getMonthlyTeamResponseCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { TJsStateSync, ZJsPublicSyncInput } from "@formbricks/types/js"; @@ -63,6 +68,8 @@ export async function GET( } if (!environment?.widgetSetupCompleted) { + const firstSurvey = getFirstSurvey(WEBAPP_URL); + await createSurvey(environmentId, firstSurvey); await updateEnvironment(environment.id, { widgetSetupCompleted: true }); } diff --git a/apps/web/app/lib/questions.ts b/apps/web/app/lib/questions.ts index 2e5acf7e15..6d58339aa0 100644 --- a/apps/web/app/lib/questions.ts +++ b/apps/web/app/lib/questions.ts @@ -36,6 +36,7 @@ export const questionTypes: TSurveyQuestionType[] = [ subheader: "Who? Who? Who?", placeholder: "Type your answer here...", longAnswer: true, + inputType: "text", }, }, { diff --git a/apps/web/app/s/[surveyId]/components/MediaBackground.tsx b/apps/web/app/s/[surveyId]/components/MediaBackground.tsx index 94a32a76a9..bfef1368c4 100644 --- a/apps/web/app/s/[surveyId]/components/MediaBackground.tsx +++ b/apps/web/app/s/[surveyId]/components/MediaBackground.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { TSurvey } from "@formbricks/types/surveys"; @@ -20,15 +20,30 @@ export const MediaBackground: React.FC = ({ ContentRef, }) => { const animatedBackgroundRef = useRef(null); + const [backgroundLoaded, setBackgroundLoaded] = useState(false); useEffect(() => { - if (survey.styling?.background?.bgType === "animation") { - if (animatedBackgroundRef.current && survey.styling?.background?.bg) { - animatedBackgroundRef.current.src = survey.styling?.background?.bg; - animatedBackgroundRef.current.play(); - } + if (survey.styling?.background?.bgType === "animation" && animatedBackgroundRef.current) { + const video = animatedBackgroundRef.current; + const onCanPlayThrough = () => setBackgroundLoaded(true); + video.addEventListener("canplaythrough", onCanPlayThrough); + video.src = survey.styling?.background?.bg || ""; + + // Cleanup + return () => video.removeEventListener("canplaythrough", onCanPlayThrough); + } else if (survey.styling?.background?.bgType === "image" && survey.styling?.background?.bg) { + // For images, we create a new Image object to listen for the 'load' event + const img = new Image(); + img.onload = () => setBackgroundLoaded(true); + img.src = survey.styling?.background?.bg; + } else { + // For colors or any other types, set to loaded immediately + setBackgroundLoaded(true); } - }, [survey.styling?.background?.bg, survey.styling?.background?.bgType]); + }, [survey.styling?.background]); + + const baseClasses = "absolute inset-0 h-full w-full transition-opacity duration-500"; + const loadedClass = backgroundLoaded ? "opacity-100" : "opacity-0"; const getFilterStyle = () => { return survey.styling?.background?.brightness @@ -38,13 +53,12 @@ export const MediaBackground: React.FC = ({ const renderBackground = () => { const filterStyle = getFilterStyle(); - const baseClasses = "absolute inset-0 h-full w-full"; switch (survey.styling?.background?.bgType) { case "color": return (
); @@ -55,23 +69,20 @@ export const MediaBackground: React.FC = ({ muted loop autoPlay - className={`${baseClasses} object-cover`} + className={`${baseClasses} ${loadedClass} object-cover`} style={{ filter: `${filterStyle}` }}> - + ); case "image": return (
); default: - return
; + return
; } }; diff --git a/apps/web/images/onboarding-churn.png b/apps/web/images/onboarding-churn.png new file mode 100644 index 0000000000..f5ee19f673 Binary files /dev/null and b/apps/web/images/onboarding-churn.png differ diff --git a/apps/web/images/onboarding-collect-feedback.png b/apps/web/images/onboarding-collect-feedback.png new file mode 100644 index 0000000000..92cf31e63d Binary files /dev/null and b/apps/web/images/onboarding-collect-feedback.png differ diff --git a/apps/web/images/onboarding-dance.gif b/apps/web/images/onboarding-dance.gif new file mode 100644 index 0000000000..688a20613d Binary files /dev/null and b/apps/web/images/onboarding-dance.gif differ diff --git a/apps/web/images/onboarding-in-app-survey.png b/apps/web/images/onboarding-in-app-survey.png new file mode 100644 index 0000000000..f7529d2b5c Binary files /dev/null and b/apps/web/images/onboarding-in-app-survey.png differ diff --git a/apps/web/images/onboarding-link-survey.webp b/apps/web/images/onboarding-link-survey.webp new file mode 100644 index 0000000000..a52183331f Binary files /dev/null and b/apps/web/images/onboarding-link-survey.webp differ diff --git a/apps/web/images/onboarding-lost.gif b/apps/web/images/onboarding-lost.gif new file mode 100644 index 0000000000..937fe99126 Binary files /dev/null and b/apps/web/images/onboarding-lost.gif differ diff --git a/apps/web/images/onboarding-nps.png b/apps/web/images/onboarding-nps.png new file mode 100644 index 0000000000..35ebb5e0bf Binary files /dev/null and b/apps/web/images/onboarding-nps.png differ diff --git a/apps/web/playwright/action.spec.ts b/apps/web/playwright/action.spec.ts index 0c50abfecc..b32fedefa3 100644 --- a/apps/web/playwright/action.spec.ts +++ b/apps/web/playwright/action.spec.ts @@ -1,7 +1,7 @@ import { actions, users } from "@/playwright/utils/mock"; import { Page, expect, test } from "@playwright/test"; -import { login, signUpAndLogin, skipOnboarding } from "./utils/helper"; +import { finishOnboarding, login, signUpAndLogin } from "./utils/helper"; const createNoCodeActionByCSSSelector = async ( page: Page, @@ -13,7 +13,7 @@ const createNoCodeActionByCSSSelector = async ( selector: string ) => { await signUpAndLogin(page, username, email, password); - await skipOnboarding(page); + await finishOnboarding(page); await page.getByRole("link", { name: "Actions & Attributes" }).click(); await page.waitForURL(/\/environments\/[^/]+\/actions/); @@ -55,7 +55,7 @@ const createNoCodeActionByPageURL = async ( testURL: string ) => { await signUpAndLogin(page, username, email, password); - await skipOnboarding(page); + await finishOnboarding(page); await page.getByRole("link", { name: "Actions & Attributes" }).click(); await page.waitForURL(/\/environments\/[^/]+\/actions/); @@ -104,7 +104,7 @@ const createNoCodeActionByInnerText = async ( innerText: string ) => { await signUpAndLogin(page, username, email, password); - await skipOnboarding(page); + await finishOnboarding(page); await page.getByRole("link", { name: "Actions & Attributes" }).click(); await page.waitForURL(/\/environments\/[^/]+\/actions/); @@ -276,7 +276,7 @@ test.describe("Create and Edit Code Action", async () => { test("Create Code Action", async ({ page }) => { await signUpAndLogin(page, username, email, password); - await skipOnboarding(page); + await finishOnboarding(page); await page.getByRole("link", { name: "Actions & Attributes" }).click(); await page.waitForURL(/\/environments\/[^/]+\/actions/); diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts index 69b381063b..2272520733 100644 --- a/apps/web/playwright/js.spec.ts +++ b/apps/web/playwright/js.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { login, replaceEnvironmentIdInHtml, signUpAndLogin, skipOnboarding } from "./utils/helper"; +import { finishOnboarding, login, replaceEnvironmentIdInHtml, signUpAndLogin } from "./utils/helper"; import { users } from "./utils/mock"; test.describe("JS Package Test", async () => { @@ -10,7 +10,7 @@ test.describe("JS Package Test", async () => { test("Admin creates an In-App Survey", async ({ page }) => { await signUpAndLogin(page, name, email, password); - await skipOnboarding(page); + await finishOnboarding(page); await page.waitForURL(/\/environments\/[^/]+\/surveys/); diff --git a/apps/web/playwright/onboarding.spec.ts b/apps/web/playwright/onboarding.spec.ts index a26a0fa686..98a6e64ff3 100644 --- a/apps/web/playwright/onboarding.spec.ts +++ b/apps/web/playwright/onboarding.spec.ts @@ -3,48 +3,32 @@ import { expect, test } from "@playwright/test"; import { signUpAndLogin } from "./utils/helper"; import { teams, users } from "./utils/mock"; -const { role, productName, useCase } = teams.onboarding[0]; +const { productName } = teams.onboarding[0]; test.describe("Onboarding Flow Test", async () => { - test("Step by Step", async ({ page }) => { + test("link survey", async ({ page }) => { const { name, email, password } = users.onboarding[0]; await signUpAndLogin(page, name, email, password); await page.waitForURL("/onboarding"); await expect(page).toHaveURL("/onboarding"); - await page.getByRole("button", { name: "Begin (1 min)" }).click(); - await page.getByLabel(role).check(); - await page.getByRole("button", { name: "Next" }).click(); - - await expect(page.getByLabel(useCase)).toBeVisible(); - await page.getByLabel(useCase).check(); - await page.getByRole("button", { name: "Next" }).click(); - - await expect(page.getByPlaceholder("e.g. Formbricks")).toBeVisible(); - await page.getByPlaceholder("e.g. Formbricks").fill(productName); - - await page.locator("#color-picker").click(); - await page.getByLabel("Hue").click(); - - await page.locator("div").filter({ hasText: "Create your team's product." }).nth(1).click(); - await page.getByRole("button", { name: "Done" }).click(); - - await page.waitForURL(/\/environments\/[^/]+\/surveys/); - await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/); - await expect(page.getByText(productName)).toBeVisible(); + await page.getByRole("button", { name: "Link Surveys Create a new" }).click(); + await page.getByRole("button", { name: "Collect Feedback Collect" }).click(); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await page.getByRole("button", { name: "Save" }).click(); }); - test("Skip", async ({ page }) => { + test("In app survey", async ({ page }) => { const { name, email, password } = users.onboarding[1]; await signUpAndLogin(page, name, email, password); await page.waitForURL("/onboarding"); await expect(page).toHaveURL("/onboarding"); - - await page.getByRole("button", { name: "I'll do it later" }).click(); - await page.getByRole("button", { name: "I'll do it later" }).click(); - + await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click(); + await page.getByRole("button", { name: "I am not sure how to do this" }).click(); + await page.locator("input").click(); + await page.locator("input").fill("test@gmail.com"); + await page.getByRole("button", { name: "Invite" }).click(); await page.waitForURL(/\/environments\/[^/]+\/surveys/); - await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/); - await expect(page.getByText("My Product")).toBeVisible(); + await expect(page.getByText(productName)).toBeVisible(); }); }); diff --git a/apps/web/playwright/team.spec.ts b/apps/web/playwright/team.spec.ts index e47854c428..c69ac40e77 100644 --- a/apps/web/playwright/team.spec.ts +++ b/apps/web/playwright/team.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "playwright/test"; -import { login, signUpAndLogin, signupUsingInviteToken, skipOnboarding } from "./utils/helper"; +import { finishOnboarding, login, signUpAndLogin, signupUsingInviteToken } from "./utils/helper"; import { invites, users } from "./utils/mock"; test.describe("Invite, accept and remove team member", async () => { @@ -10,7 +10,7 @@ test.describe("Invite, accept and remove team member", async () => { test("Invite team member", async ({ page }) => { await signUpAndLogin(page, name, email, password); - await skipOnboarding(page); + await finishOnboarding(page); const dropdownTrigger = page.locator("#userDropdownTrigger"); await expect(dropdownTrigger).toBeVisible(); @@ -84,7 +84,7 @@ test.describe("Invite, accept and remove team member", async () => { await page.getByRole("link", { name: "Create account" }).click(); await signupUsingInviteToken(page, name, email, password); - await skipOnboarding(page); + await finishOnboarding(page); }); test("Remove member", async ({ page }) => { diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index a1885833d6..81b1459e23 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -37,14 +37,15 @@ export const login = async (page: Page, email: string, password: string): Promis await page.getByRole("button", { name: "Login with Email" }).click(); }; -export const skipOnboarding = async (page: Page): Promise => { +export const finishOnboarding = async (page: Page): Promise => { await page.waitForURL("/onboarding"); await expect(page).toHaveURL("/onboarding"); - await page.getByRole("button", { name: "I'll do it later" }).click(); - await page.waitForTimeout(500); - await page.getByRole("button", { name: "I'll do it later" }).click(); + await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click(); + await page.getByRole("button", { name: "I am not sure how to do this" }).click(); + await page.locator("input").click(); + await page.locator("input").fill("test@gmail.com"); + await page.getByRole("button", { name: "Invite" }).click(); await page.waitForURL(/\/environments\/[^/]+\/surveys/); - await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/); await expect(page.getByText("My Product")).toBeVisible(); }; @@ -87,7 +88,7 @@ export const createSurvey = async ( const addQuestion = "Add QuestionAdd a new question to your survey"; await signUpAndLogin(page, name, email, password); - await skipOnboarding(page); + await finishOnboarding(page); await page.getByRole("heading", { name: "Start from Scratch" }).click(); diff --git a/apps/web/playwright/utils/mock.ts b/apps/web/playwright/utils/mock.ts index d70bd0b14e..7a8fc56404 100644 --- a/apps/web/playwright/utils/mock.ts +++ b/apps/web/playwright/utils/mock.ts @@ -21,7 +21,7 @@ export const users = { survey: [ { name: "Survey User 1", - email: "survey1@formbricks.com", + email: "survey3@formbricks.com", password: "Y1I*EpURUSb32j5XijP", }, { @@ -83,7 +83,7 @@ export const teams = { { role: "Founder", useCase: "Increase conversion", - productName: "Formbricks E2E Test Suite", + productName: "My Product", }, ], }; diff --git a/apps/web/public/onboarding/meme.png b/apps/web/public/onboarding/meme.png new file mode 100644 index 0000000000..4c12b965f3 Binary files /dev/null and b/apps/web/public/onboarding/meme.png differ diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 6ad55b7606..7d90f55144 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -7,7 +7,7 @@ "maxDuration": 10, "memory": 300 }, - "app/**/*.ts": { + "**/*.ts": { "maxDuration": 10, "memory": 512 } diff --git a/packages/lib/emails/emails.ts b/packages/lib/emails/emails.ts index 667b4d05ec..13d7d2ad7b 100644 --- a/packages/lib/emails/emails.ts +++ b/packages/lib/emails/emails.ts @@ -126,7 +126,9 @@ export const sendInviteMemberEmail = async ( inviteId: string, email: string, inviterName: string | null, - inviteeName: string | null + inviteeName: string | null, + isOnboardingInvite?: boolean, + inviteMessage?: string ) => { const token = createInviteToken(inviteId, email, { expiresIn: "7d", @@ -134,16 +136,35 @@ export const sendInviteMemberEmail = async ( const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`; - await sendEmail({ - to: email, - subject: `You're invited to collaborate on Formbricks!`, - html: withEmailTemplate(`Hey ${inviteeName},

- Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:

- Join team
-
- Have a great day!
- The Formbricks Team!`), - }); + if (isOnboardingInvite && inviteMessage) { + await sendEmail({ + to: email, + subject: `${inviterName} needs a hand setting up Formbricks. Can you help out?`, + html: withEmailTemplate(`Hey πŸ‘‹,

+ ${inviteMessage} +

Get Started in Minutes

+
    +
  1. Create an account to join ${inviterName}'s team.
  2. +
  3. Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.
  4. +
  5. Done βœ…
  6. +
+ Join ${inviterName}'s team
+
+ Have a great day!
+ The Formbricks Team!`), + }); + } else { + await sendEmail({ + to: email, + subject: `You're invited to collaborate on Formbricks!`, + html: withEmailTemplate(`Hey ${inviteeName},

+ Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:

+ Join team
+
+ Have a great day!
+ The Formbricks Team!`), + }); + } }; export const sendInviteAcceptedEmail = async (inviterName: string, inviteeName: string, email: string) => { diff --git a/packages/lib/invite/service.ts b/packages/lib/invite/service.ts index 1c7124c40a..c942a93e54 100644 --- a/packages/lib/invite/service.ts +++ b/packages/lib/invite/service.ts @@ -194,10 +194,14 @@ export const inviteUser = async ({ currentUser, invitee, teamId, + isOnboardingInvite, + inviteMessage, }: { teamId: string; invitee: TInvitee; currentUser: TCurrentUser; + isOnboardingInvite?: boolean; + inviteMessage?: string; }): Promise => { validateInputs([teamId, ZString], [invitee, ZInvitee], [currentUser, ZCurrentUser]); @@ -239,6 +243,6 @@ export const inviteUser = async ({ teamId: invite.teamId, }); - await sendInviteMemberEmail(invite.id, email, currentUserName, name); + await sendInviteMemberEmail(invite.id, email, currentUserName, name, isOnboardingInvite, inviteMessage); return invite; }; diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index a6eec2112a..932b8aa6e1 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -31,6 +31,15 @@ import { validateInputs } from "../utils/validate"; import { surveyCache } from "./cache"; import { anySurveyHasFilters } from "./util"; +interface TriggerUpdate { + create?: Array<{ actionClassId: string }>; + deleteMany?: { + actionClassId: { + in: string[]; + }; + }; +} + export const selectSurvey = { id: true, createdAt: true, @@ -100,6 +109,47 @@ const revalidateSurveyByActionClassId = (actionClasses: TActionClass[], actionCl } }; +const processTriggerUpdates = ( + triggers: string[], + currentSurveyTriggers: string[], + actionClasses: TActionClass[] +) => { + const newTriggers: string[] = []; + const removedTriggers: string[] = []; + + // find added triggers + for (const trigger of triggers) { + if (!trigger || currentSurveyTriggers.includes(trigger)) { + continue; + } + newTriggers.push(trigger); + } + + // find removed triggers + for (const trigger of currentSurveyTriggers) { + if (!triggers.includes(trigger)) { + removedTriggers.push(getActionClassIdFromName(actionClasses, trigger)); + } + } + + // Construct the triggers update object + const triggersUpdate: TriggerUpdate = {}; + + if (newTriggers.length > 0) { + triggersUpdate.create = newTriggers.map((trigger) => ({ + actionClassId: getActionClassIdFromName(actionClasses, trigger), + })); + } + + if (removedTriggers.length > 0) { + triggersUpdate.deleteMany = { + actionClassId: { in: removedTriggers }, + }; + } + revalidateSurveyByActionClassId(actionClasses, [...newTriggers, ...removedTriggers]); + return triggersUpdate; +}; + export const getSurvey = async (surveyId: string): Promise => { const survey = await unstable_cache( async () => { @@ -281,52 +331,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => const { triggers, environmentId, segment, ...surveyData } = updatedSurvey; if (triggers) { - const newTriggers: string[] = []; - const removedTriggers: string[] = []; - - // find added triggers - for (const trigger of triggers) { - if (!trigger) { - continue; - } - if (currentSurvey.triggers.find((t) => t === trigger)) { - continue; - } else { - newTriggers.push(trigger); - } - } - - // find removed triggers - for (const trigger of currentSurvey.triggers) { - if (triggers.find((t: any) => t === trigger)) { - continue; - } else { - removedTriggers.push(trigger); - } - } - // create new triggers - if (newTriggers.length > 0) { - data.triggers = { - ...(data.triggers || []), - create: newTriggers.map((trigger) => ({ - actionClassId: getActionClassIdFromName(actionClasses, trigger), - })), - }; - } - // delete removed triggers - if (removedTriggers.length > 0) { - data.triggers = { - ...(data.triggers || []), - deleteMany: { - actionClassId: { - in: removedTriggers.map((trigger) => getActionClassIdFromName(actionClasses, trigger)), - }, - }, - }; - } - - // Revalidation for newly added/removed actionClassId - revalidateSurveyByActionClassId(actionClasses, [...newTriggers, ...removedTriggers]); + data.triggers = processTriggerUpdates(triggers, currentSurvey.triggers, actionClasses); } if (segment) { @@ -441,8 +446,10 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp const data: Omit = { ...surveyBody, - // TODO: Create with triggers & attributeFilters - triggers: undefined, + // TODO: Create with attributeFilters + triggers: surveyBody.triggers + ? processTriggerUpdates(surveyBody.triggers, [], await getActionClasses(environmentId)) + : undefined, attributeFilters: undefined, }; diff --git a/packages/lib/survey/tests/survey.test.ts b/packages/lib/survey/tests/survey.test.ts index f15725b305..d3d37d63ba 100644 --- a/packages/lib/survey/tests/survey.test.ts +++ b/packages/lib/survey/tests/survey.test.ts @@ -3,7 +3,7 @@ import { prisma } from "../../__mocks__/database"; import { Prisma } from "@prisma/client"; import { beforeEach, describe, expect, it } from "vitest"; -import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { testInputValidation } from "../../vitestSetup"; import { @@ -204,6 +204,7 @@ describe("Tests for createSurvey", () => { it("Creates a survey successfully", async () => { prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput); prisma.team.findFirst.mockResolvedValueOnce(mockTeamOutput); + prisma.actionClass.findMany.mockResolvedValue([mockActionClass]); prisma.user.findMany.mockResolvedValueOnce([ { ...mockUser, diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index b14ee541ef..fa378c95b1 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -6,13 +6,13 @@ "downlevelIteration": true, "baseUrl": ".", "paths": { - "@prisma/client/*": ["@formbricks/database/client/*"] + "@prisma/client/*": ["@formbricks/database/client/*"], }, "plugins": [ { - "name": "next" - } + "name": "next", + }, ], - "strictNullChecks": true - } + "strictNullChecks": true, + }, } diff --git a/packages/lib/user/service.ts b/packages/lib/user/service.ts index 51e729114a..d3d40ff7ce 100644 --- a/packages/lib/user/service.ts +++ b/packages/lib/user/service.ts @@ -168,8 +168,6 @@ export const createUser = async (data: TUserCreateInput): Promise => { select: responseSelection, }); - console.log("user", user); - userCache.revalidate({ email: user.email, id: user.id, @@ -210,6 +208,7 @@ export const deleteUser = async (id: string): Promise => { const teamHasAtLeastOneAdmin = teamAdminMemberships.length > 0; const teamHasOnlyOneMember = teamMemberships.length === 1; const currentUserIsTeamOwner = role === "owner"; + await deleteMembership(id, teamId); if (teamHasOnlyOneMember) { await deleteTeam(teamId); @@ -219,8 +218,6 @@ export const deleteUser = async (id: string): Promise => { } else if (currentUserIsTeamOwner) { await deleteTeam(teamId); } - - await deleteMembership(id, teamId); } const deletedUser = await deleteUserById(id); diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx index e9b207b7bb..d240ce4f93 100644 --- a/packages/surveys/src/components/general/Survey.tsx +++ b/packages/surveys/src/components/general/Survey.tsx @@ -150,6 +150,9 @@ export function Survey({ const finished = nextQuestionId === "end"; onResponse({ data: responseData, ttc, finished }); if (finished) { + // Dispatching a custom event when the survey is completed + const event = new CustomEvent("formbricksSurveyCompleted", { detail: { surveyId: survey.id } }); + window.top?.dispatchEvent(event); onFinished(); } setQuestionId(nextQuestionId); diff --git a/packages/surveys/src/components/general/ThankYouCard.tsx b/packages/surveys/src/components/general/ThankYouCard.tsx index 6bcb1acc71..f92031c91c 100644 --- a/packages/surveys/src/components/general/ThankYouCard.tsx +++ b/packages/surveys/src/components/general/ThankYouCard.tsx @@ -75,7 +75,7 @@ export default function ThankYouCard({ isLastQuestion={false} onClick={() => { if (!buttonLink) return; - window.location.href = buttonLink; + window.location.replace(buttonLink); }} />

Press Enter ↡

diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js index a98dfc8765..2e6df32452 100644 --- a/packages/tailwind-config/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -21,6 +21,11 @@ module.exports = { backgroundImage: { "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", }, + boxShadow: { + "card-sm": "0px 0.5px 12px -5px rgba(30,41,59,0.20)", + "card-md": "0px 1px 25px -10px rgba(30,41,59,0.30)", + "card-lg": "0px 2px 51px -19px rgba(30,41,59,0.40)", + }, colors: { brand: { DEFAULT: "#00E6CA", diff --git a/packages/types/surveys.ts b/packages/types/surveys.ts index 80c59a6d1a..4bd6cb0047 100644 --- a/packages/types/surveys.ts +++ b/packages/types/surveys.ts @@ -391,7 +391,7 @@ export const ZSurveyQuestions = z.array(ZSurveyQuestion); export type TSurveyQuestions = z.infer; -const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "respondMultiple"]); +export const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "respondMultiple"]); export type TSurveyDisplayOption = z.infer; diff --git a/packages/ui/CodeBlock/index.tsx b/packages/ui/CodeBlock/index.tsx index a4c2c869c0..1a6383dac9 100644 --- a/packages/ui/CodeBlock/index.tsx +++ b/packages/ui/CodeBlock/index.tsx @@ -8,16 +8,20 @@ import toast from "react-hot-toast"; import { cn } from "@formbricks/lib/cn"; +import "./style.css"; + interface CodeBlockProps { children: React.ReactNode; language: string; customCodeClass?: string; + customEditorClass?: string; showCopyToClipboard?: boolean; } const CodeBlock: React.FC = ({ children, language, + customEditorClass = "", customCodeClass = "", showCopyToClipboard = true, }) => { @@ -28,16 +32,18 @@ const CodeBlock: React.FC = ({ return (
{showCopyToClipboard && ( - { - const childText = children?.toString() || ""; - navigator.clipboard.writeText(childText); - toast.success("Copied to clipboard"); - }} - /> +
+ { + const childText = children?.toString() || ""; + navigator.clipboard.writeText(childText); + toast.success("Copied to clipboard"); + }} + /> +
)} -
+      
         {children}
       
diff --git a/packages/ui/CodeBlock/style.css b/packages/ui/CodeBlock/style.css new file mode 100644 index 0000000000..a688902d9c --- /dev/null +++ b/packages/ui/CodeBlock/style.css @@ -0,0 +1,21 @@ +pre { + scrollbar-width: thin; + scrollbar-color: #e2e8f0 #ffffff; + } + + pre::-webkit-scrollbar { + width: 4px !important; + border-radius: 99px; + } + + pre::-webkit-scrollbar-track { + background: #e2e8f0; + border-radius: 99px; + } + +pre::-webkit-scrollbar-thumb { + background-color: #cbd5e1; + border: 3px solid #cbd5e1; + border-radius: 99px; + } + \ No newline at end of file diff --git a/packages/ui/OptionCard/index.tsx b/packages/ui/OptionCard/index.tsx new file mode 100644 index 0000000000..c05081999f --- /dev/null +++ b/packages/ui/OptionCard/index.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +import LoadingSpinner from "../LoadingSpinner"; + +interface PathwayOptionProps { + size: "sm" | "md" | "lg"; + title: string; + description: string; + loading?: boolean; + onSelect: () => void; + children?: React.ReactNode; +} + +const sizeClasses = { + sm: "rounded-lg border border-slate-200 shadow-card-sm transition-all duration-150", + md: "rounded-xl border border-slate-200 shadow-card-md transition-all duration-300", + lg: "rounded-2xl border border-slate-200 shadow-card-lg transition-all duration-500", +}; + +export const OptionCard: React.FC = ({ + size, + title, + description, + children, + onSelect, + loading, +}) => ( +
+
+
+ {children} +
+

{title}

+

{description}

+
+
+
+ {loading && ( +
+ +
+ )} +
+); diff --git a/packages/ui/ProgressBar/index.tsx b/packages/ui/ProgressBar/index.tsx index ba86a242d1..a0e83e18e5 100644 --- a/packages/ui/ProgressBar/index.tsx +++ b/packages/ui/ProgressBar/index.tsx @@ -13,7 +13,7 @@ export const ProgressBar: React.FC = ({ progress, barColor, he
+ style={{ width: `${Math.floor(progress * 100)}%`, transition: "width 0.5s ease-out" }}>
); }; diff --git a/packages/ui/SurveysList/index.tsx b/packages/ui/SurveysList/index.tsx index 01b20fafa2..adc31071a0 100644 --- a/packages/ui/SurveysList/index.tsx +++ b/packages/ui/SurveysList/index.tsx @@ -30,14 +30,12 @@ export default function SurveysList({ const [filteredSurveys, setFilteredSurveys] = useState(surveys); // Initialize orientation state with a function that checks if window is defined const [orientation, setOrientation] = useState(() => - typeof window !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid" + typeof localStorage !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid" ); // Save orientation to localStorage useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem("surveyOrientation", orientation); - } + localStorage.setItem("surveyOrientation", orientation); }, [orientation]); return ( diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 757a8dd6e7..cea88f2605 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -3,6 +3,6 @@ "include": [".", "../types/*.d.ts"], "exclude": ["build", "node_modules"], "compilerOptions": { - "lib": ["ES2021.String"] - } + "lib": ["ES2021.String"], + }, }