Compare commits

...

42 Commits

Author SHA1 Message Date
review-agent-prime[bot]
40df928974 Edit packages/database/migrations/20240229062232_adds_styling_column_to_product_model/data-migration.ts 2024-03-04 16:40:22 +00:00
pandeymangg
7a1af85141 fix: product settings 2024-03-04 22:07:13 +05:30
pandeymangg
2586a3ba3a feat: surveys package styling changes 2024-03-04 15:39:24 +05:30
pandeymangg
77408bf0b0 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/form-styling 2024-03-04 13:19:00 +05:30
pandeymangg
d5b183155b feat: product settings page styling UI and service 2024-03-04 13:18:39 +05:30
Dhruwang Jariwala
6c1989b527 fix: break lines at new line character in single response card (#2177) 2024-03-04 07:20:23 +00:00
Dhruwang Jariwala
96bc0e669c fix: Recall in verify email question preview and integrations (#2145)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-03-02 11:30:07 +00:00
Matti Nannt
9791490449 chore: update npm deps & survey/js package version (#2171) 2024-03-02 11:17:53 +00:00
Shubham Palriwala
ee053e6642 feat: webapp URL based redirects in middleware for external LB integrations (#2151)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-03-02 11:00:20 +00:00
Anshuman Pandey
56f6dbe9a6 feat: sync endpoint error handling (#2132)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-03-02 10:53:39 +00:00
Johannes
49c18023bd chore: add css ids to onboarding (#2170) 2024-03-02 09:02:53 +00:00
Johannes
3f5f29122b fix: add onboarding tracking (#2169) 2024-03-01 15:54:37 +00:00
Anshuman Pandey
2ed03bc8da fix: fixes duplicate survey segment (#2168) 2024-03-01 13:30:06 +00:00
Shubham Palriwala
5aa72a4c76 feat: show error on browser console for easier debugging for webhook test endpoint (#2163) 2024-03-01 12:03:12 +00:00
Anshuman Pandey
4a8fdcbbbc fix: saved actions (#2167) 2024-03-01 11:42:34 +00:00
Johannes
5855804291 fix: remove naviate from save (#2166) 2024-03-01 11:05:03 +00:00
Anshuman Pandey
9fdb7452a2 hotfix: e2e tests (#2164) 2024-03-01 10:57:12 +00:00
Johannes
7573b2d0ba fix: height issue (#2165) 2024-03-01 10:39:39 +00:00
Dhruwang Jariwala
cdd93ee86b fix: Cta button issue on thank you card (#2148)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-03-01 09:37:42 +00:00
Matti Nannt
dbbd450b62 chore: improve person & attribute query performance (#2161) 2024-03-01 07:36:49 +00:00
pandeymangg
d7fc7995bc wip 2024-02-29 18:37:11 +05:30
Matti Nannt
255f1cee61 fix: vercel-config failing on Vercel (#2159) 2024-02-29 12:40:36 +01:00
pandeymangg
a9d8239a25 UI 2024-02-29 14:07:10 +05:30
Johannes
36ac4ecdb9 fix: isCloud condition (#2156) 2024-02-29 09:32:46 +01:00
Thomas Kaul
4edb92365a fix: Typo in OSS Friends API (#2155)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-02-29 08:47:28 +01:00
Dhruwang Jariwala
89eee21978 fix: allow decimal in number input field (#2149) 2024-02-29 08:46:51 +01:00
pandeymangg
71bdb5095a fix: schema 2024-02-29 11:51:45 +05:30
pandeymangg
b84e322eee Merge branch 'main' into feat/form-styling 2024-02-29 11:49:41 +05:30
Dhruwang Jariwala
a8563ad905 feat: Onboarding revamp (#2073)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-02-28 21:35:10 +01:00
Matti Nannt
06eebe36ee fix: update action indexes for faster query processing (#2154) 2024-02-28 18:17:40 +00:00
Shubham Palriwala
5fc18fc445 fix: increase max height of open text question input to 8 lines (#2153) 2024-02-28 16:15:50 +00:00
Shubham Palriwala
53d3be3b27 feat: identify teams & their billing in Posthog (#2112) 2024-02-28 09:59:27 +00:00
pandeymangg
08ccb954f3 fix: styling object 2024-02-28 14:43:10 +05:30
pandeymangg
38c6cb01df fix: styling object 2024-02-28 14:42:38 +05:30
pandeymangg
2c13121487 fix: data migration and product types 2024-02-28 12:04:06 +05:30
pandeymangg
73f1d09dc8 Merge branch 'main' into feat/form-styling 2024-02-28 11:24:42 +05:30
pandeymangg
1f884a408c feat: styling zod schema and data migration 2024-02-28 11:24:27 +05:30
pandeymangg
ed2253dcfc feat: styling zod schema and data migration 2024-02-28 11:23:58 +05:30
Dhruwang Jariwala
078c5db2b0 fix: preview bugs (#2134) 2024-02-28 05:51:53 +00:00
Shubham Palriwala
356d237e60 fix: hidden fields object was incorrectly required in survey creation api (#2140) 2024-02-27 16:44:00 +00:00
Shubham Palriwala
e799aa9b37 fix: authorization checks across all billing pages (#2143) 2024-02-27 16:31:53 +00:00
Matti Nannt
b36a263ef6 fix: invite token of null when callbackUrl present (#2142) 2024-02-27 15:41:41 +00:00
144 changed files with 4850 additions and 2686 deletions

View File

@@ -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",
}

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@heroicons/react": "^2.1.1",
"next": "14.1.0",
"next": "14.1.1",
"react": "18.2.0",
"react-dom": "18.2.0"
},

View File

@@ -52,7 +52,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and thats abou
```
</CodeGroup>
</Col>
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">
@@ -112,7 +112,7 @@ export default App;
</CodeGroup>
</Col>
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">
@@ -256,7 +256,7 @@ export default function App({ Component, pageProps }: AppProps) {
</Col>
Refer to our [Example NextJS Pages Directory project](https://github.com/formbricks/examples/tree/main/nextjs-pages) for more help!
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">
@@ -345,7 +345,7 @@ router.afterEach((to, from) => {
</CodeGroup>
</Col>
### Required Customizations to be Made
### Required customizations to be made
<Properties>
<Property name="environment-id" type="string">

View File

@@ -18,7 +18,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
{
name: "Argos",
description: "Argos provides the developer tools to debug tests and detect visual regressions..",
description: "Argos provides the developer tools to debug tests and detect visual regressions.",
href: "https://argos-ci.com",
},
{

View File

@@ -66,6 +66,7 @@ export default function AddProductModal({ environmentId, open, setOpen }: AddPro
<div>
<Label>Name</Label>
<Input
autoFocus
placeholder="e.g. My New Product"
{...register("name", { required: true })}
value={productName}

View File

@@ -5,24 +5,54 @@ import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { env } from "@formbricks/lib/env";
import { TSubscriptionStatus } from "@formbricks/types/teams";
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
export default function PosthogIdentify({
session,
environmentId,
teamId,
teamName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
}: {
session: Session;
environmentId: string;
environmentId?: string;
teamId?: string;
teamName?: string;
inAppSurveyBillingStatus?: TSubscriptionStatus;
linkSurveyBillingStatus?: TSubscriptionStatus;
userTargetingBillingStatus?: TSubscriptionStatus;
}) {
const posthog = usePostHog();
useEffect(() => {
if (posthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, { name: session.user.name, email: session.user.email });
posthog.group("environment", environmentId, { name: environmentId });
if (environmentId) {
posthog.group("environment", environmentId, { name: environmentId });
}
if (teamId) {
posthog.group("team", teamId, {
name: teamName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
});
}
}
}, [session, environmentId, posthog]);
}, [
posthog,
session.user,
environmentId,
teamId,
teamName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
]);
return null;
}

View File

@@ -8,6 +8,7 @@ import { useEffect, useState } from "react";
import { Control, Controller, UseFormSetValue, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
@@ -333,7 +334,7 @@ export default function AddIntegrationModal(props: AddIntegrationModalProps) {
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{selectedSurvey?.questions.map((question) => (
{checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => (
<Controller
key={question.id}
control={control}

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationGoogleSheets,
@@ -273,7 +274,7 @@ export default function AddIntegrationModal({
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{selectedSurvey?.questions.map((question) => (
{checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox

View File

@@ -13,6 +13,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationNotion,
@@ -105,12 +106,13 @@ export default function AddIntegrationModal({
}, [selectedDatabase?.id]);
const questionItems = useMemo(() => {
const questions =
selectedSurvey?.questions.map((q) => ({
id: q.id,
name: q.headline,
type: q.type,
})) || [];
const questions = selectedSurvey
? checkForRecallInHeadline(selectedSurvey)?.questions.map((q) => ({
id: q.id,
name: q.headline,
type: q.type,
}))
: [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({

View File

@@ -53,7 +53,8 @@ export default function AddWebhookModal({ environmentId, surveys, open, setOpen
return true;
} catch (err) {
setHittingEndpoint(false);
toast.error("Oh no! We are unable to ping the webhook!");
toast.error("Unable to ping the webhook! Please check browser console for logs");
console.error("Webhook Test Failed due to: ", err.message);
setEndpointAccessible(false);
return false;
}

View File

@@ -63,7 +63,8 @@ export default function WebhookSettingsTab({
return true;
} catch (err) {
setHittingEndpoint(false);
toast.error("Oh no! We are unable to ping the webhook!");
toast.error("Unable to ping the webhook! Please check browser console for logs");
console.error("Webhook Test Failed due to: ", err.message);
setEndpointAccessible(false);
return false;
}

View File

@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { AuthorizationError } from "@formbricks/types/errors";
import ToasterClient from "@formbricks/ui/ToasterClient";
@@ -22,10 +23,23 @@ export default async function EnvironmentLayout({ children, params }) {
throw new AuthorizationError("Not authorized");
}
const team = await getTeamByEnvironmentId(params.environmentId);
if (!team) {
throw new Error("Team not found");
}
return (
<>
<ResponseFilterProvider>
<PosthogIdentify session={session} environmentId={params.environmentId} />
<PosthogIdentify
session={session}
environmentId={params.environmentId}
teamId={team.id}
teamName={team.name}
inAppSurveyBillingStatus={team.billing.features.inAppSurvey.status}
linkSurveyBillingStatus={team.billing.features.linkSurvey.status}
userTargetingBillingStatus={team.billing.features.userTargeting.status}
/>
<FormbricksClient session={session} />
<ToasterClient />
<EnvironmentsNavbar

View File

@@ -0,0 +1,36 @@
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
export const metadata: Metadata = {
title: "Billing",
};
export default async function BillingLayout({ children, params }) {
if (!IS_FORMBRICKS_CLOUD) {
notFound();
}
const session = await getServerSession(authOptions);
const team = await getTeamByEnvironmentId(params.environmentId);
if (!session) {
throw new Error("Unauthorized");
}
if (!team) {
throw new Error("Team not found");
}
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isAdmin, isOwner } = getAccessFlags(currentUserMembership?.role);
const isPricingDisabled = !isOwner && !isAdmin;
return <>{!isPricingDisabled ? <>{children}</> : <ErrorComponent />}</>;
}

View File

@@ -1,37 +1,15 @@
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import {
IS_FORMBRICKS_CLOUD,
PRICING_APPSURVEYS_FREE_RESPONSES,
PRICING_USERTARGETING_FREE_MTU,
} from "@formbricks/lib/constants";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { PRICING_APPSURVEYS_FREE_RESPONSES, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import {
getMonthlyActiveTeamPeopleCount,
getMonthlyTeamResponseCount,
getTeamByEnvironmentId,
} from "@formbricks/lib/team/service";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import SettingsTitle from "../components/SettingsTitle";
import PricingTable from "./components/PricingTable";
export default async function BillingPage({ params }) {
if (!IS_FORMBRICKS_CLOUD) {
notFound();
}
const session = await getServerSession(authOptions);
const team = await getTeamByEnvironmentId(params.environmentId);
if (!session) {
throw new Error("Unauthorized");
}
if (!team) {
throw new Error("Team not found");
}
@@ -40,26 +18,19 @@ export default async function BillingPage({ params }) {
getMonthlyActiveTeamPeopleCount(team.id),
getMonthlyTeamResponseCount(team.id),
]);
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isAdmin, isOwner } = getAccessFlags(currentUserMembership?.role);
const isPricingDisabled = !isOwner && !isAdmin;
return (
<>
<div>
<SettingsTitle title="Billing & Plan" />
{!isPricingDisabled ? (
<PricingTable
team={team}
environmentId={params.environmentId}
peopleCount={peopleCount}
responseCount={responseCount}
userTargetingFreeMtu={PRICING_USERTARGETING_FREE_MTU}
inAppSurveyFreeResponses={PRICING_APPSURVEYS_FREE_RESPONSES}
/>
) : (
<ErrorComponent />
)}
<PricingTable
team={team}
environmentId={params.environmentId}
peopleCount={peopleCount}
responseCount={responseCount}
userTargetingFreeMtu={PRICING_USERTARGETING_FREE_MTU}
inAppSurveyFreeResponses={PRICING_APPSURVEYS_FREE_RESPONSES}
/>
</div>
</>
);

View File

@@ -1,26 +1,12 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { redirect } from "next/navigation";
import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants";
import { authOptions } from "@formbricks/lib/authOptions";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { upgradePlanAction } from "../actions";
export default async function UnlimitedPage({ params }) {
if (!IS_FORMBRICKS_CLOUD) {
notFound();
}
const session = await getServerSession(authOptions);
const team = await getTeamByEnvironmentId(params.environmentId);
if (!session) {
throw new Error("Unauthorized");
}
if (!team) {
throw new Error("Team not found");
}

View File

@@ -1,26 +1,12 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { redirect } from "next/navigation";
import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants";
import { authOptions } from "@formbricks/lib/authOptions";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { upgradePlanAction } from "../actions";
export default async function UnlimitedPage({ params }) {
if (!IS_FORMBRICKS_CLOUD) {
notFound();
}
const session = await getServerSession(authOptions);
const team = await getTeamByEnvironmentId(params.environmentId);
if (!session) {
throw new Error("Unauthorized");
}
if (!team) {
throw new Error("Team not found");
}

View File

@@ -43,7 +43,7 @@ export default async function SettingsLayout({ children, params }) {
membershipRole={currentUserMembership?.role}
/>
<div className="w-full md:ml-64">
<div className="max-w-4xl px-20 pb-6 pt-14 md:pt-6">
<div className="px-20 pb-6 pt-14 md:pt-6">
<div>{children}</div>
</div>
</div>

View File

@@ -0,0 +1,406 @@
"use client";
import UnifiedStylingPreviewSurvey from "@/app/(app)/environments/[environmentId]/settings/lookandfeel/components/UnifiedStylingPreviewSurvey";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { Slider } from "@formbricks/ui/Slider";
import CardArrangement from "@formbricks/ui/Styling/CardArrangement";
import ColorSelectorWithLabel from "@formbricks/ui/Styling/ColorSelectorWithLabel";
import DarkModeColors from "@formbricks/ui/Styling/DarkModeColors";
import { Switch } from "@formbricks/ui/Switch";
import { updateProductAction } from "../actions";
type UnifiedStylingProps = {
product: TProduct;
};
const colorDefaults = {
brandColor: "#64748b",
questionColor: "#2b2524",
inputColor: "#efefef",
inputBorderColor: "#c0c0c0",
cardBackgroundColor: "#c0c0c0",
highlighBorderColor: "#64748b",
};
const previewSurvey = {
id: "cltcppyqk00006uothzb3ybh0",
createdAt: new Date(),
updatedAt: new Date(),
name: "Product Market Fit (Superhuman)",
type: "link",
environmentId: "cltcf8i2n00099wlx7cu12zi6",
createdBy: "cltcf8i1c00009wlx3sk1ryss",
status: "draft",
welcomeCard: {
html: "Thanks for providing your feedback - let's go!",
enabled: false,
headline: "Welcome!",
timeToFinish: true,
showResponseCount: false,
},
questions: [
{
id: "uvnrhtngswxlibktglanh45f",
type: "openText",
headline: "This is a preview survey",
required: true,
inputType: "text",
subheader: "Click through it to check the look and feel of the surveying experience.",
longAnswer: true,
placeholder: "Type your answer here...",
},
{
id: "swfnndfht0ubsu9uh17tjcej",
type: "rating",
range: 5,
scale: "star",
headline: "How would you rate My Product",
required: true,
subheader: "Don't worry, be honest.",
lowerLabel: "Not good",
upperLabel: "Very good",
},
{
id: "je70a714xjdxc70jhxgv5web",
type: "multipleChoiceSingle",
choices: [
{
id: "vx9q4mlr6ffaw35m99bselwm",
label: "Eat the cake 🍰",
},
{
id: "ynj051qawxd4dszxkbvahoe5",
label: "Have the cake 🎂",
},
],
headline: "What do you do?",
required: true,
subheader: "Can't do both.",
shuffleOption: "none",
},
],
thankYouCard: {
enabled: true,
headline: "Thank you!",
subheader: "We appreciate your feedback.",
buttonLink: "https://formbricks.com/signup",
buttonLabel: "Create your own Survey",
},
hiddenFields: {
enabled: true,
fieldIds: [],
},
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
verifyEmail: null,
redirectUrl: null,
productOverwrites: null,
styling: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,
isEncrypted: true,
},
pin: null,
resultShareKey: null,
triggers: [],
inlineTriggers: null,
segment: null,
};
const UnifiedStyling = ({ product }: UnifiedStylingProps) => {
const router = useRouter();
const [unifiedStyling, setUnifiedStyling] = useState(product.styling?.unifiedStyling ?? false);
const [allowStyleOverwrite, setAllowStyleOverwrite] = useState(
product.styling?.allowStyleOverwrite ?? false
);
const [brandColor, setBrandColor] = useState(
product.styling?.brandColor?.light ?? colorDefaults.brandColor
);
const [questionColor, setQuestionColor] = useState(
product.styling?.questionColor?.light ?? colorDefaults.questionColor
);
const [inputColor, setInputColor] = useState(
product.styling?.inputColor?.light ?? colorDefaults.inputColor
);
const [inputBorderColor, setInputBorderColor] = useState(
product.styling?.inputBorderColor?.light ?? colorDefaults.inputBorderColor
);
const [cardBackgroundColor, setCardBackgroundColor] = useState(
product.styling?.cardBackgroundColor?.light ?? colorDefaults.cardBackgroundColor
);
// highlight border
const [allowHighlightBorder, setAllowHighlightBorder] = useState(
!!product.styling?.highlightBorderColor?.light ?? false
);
const [highlightBorderColor, setHighlightBorderColor] = useState(
product.styling?.highlightBorderColor?.light ?? colorDefaults.highlighBorderColor
);
const [isDarkMode, setIsDarkMode] = useState(product.styling?.isDarkModeEnabled ?? false);
const [brandColorDark, setBrandColorDark] = useState(product.styling?.brandColor?.dark);
const [questionColorDark, setQuestionColorDark] = useState(product.styling?.questionColor?.dark);
const [inputColorDark, setInputColorDark] = useState(product.styling?.inputColor?.dark);
const [inputBorderColorDark, setInputBorderColorDark] = useState(product.styling?.inputBorderColor?.dark);
const [cardBackgroundColorDark, setCardBackgroundColorDark] = useState(
product.styling?.cardBackgroundColor?.dark
);
const [highlightBorderColorDark, setHighlightBorderColorDark] = useState(
product.styling?.highlightBorderColor?.dark
);
const [roundness, setRoundness] = useState(product.styling?.roundness ?? 8);
const [linkSurveysCardArrangement, setLinkSurveysCardArrangement] = useState(
product.styling?.cardArrangement?.linkSurveys ?? "casual"
);
const [inAppSurveysCardArrangement, setInAppSurveysCardArrangement] = useState(
product.styling?.cardArrangement?.inAppSurveys ?? "casual"
);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
useEffect(() => {
setActiveQuestionId(previewSurvey.questions[0].id);
}, []);
useEffect(() => {
if (!unifiedStyling) {
setAllowStyleOverwrite(false);
}
}, [unifiedStyling]);
const onSave = async () => {
await updateProductAction(product.id, {
styling: {
unifiedStyling,
allowStyleOverwrite,
brandColor: {
light: brandColor,
dark: brandColorDark,
},
questionColor: {
light: questionColor,
dark: questionColorDark,
},
inputColor: {
light: inputColor,
dark: inputColorDark,
},
inputBorderColor: {
light: inputBorderColor,
dark: inputBorderColorDark,
},
cardBackgroundColor: {
light: cardBackgroundColor,
dark: cardBackgroundColorDark,
},
highlightBorderColor: allowHighlightBorder
? {
light: highlightBorderColor,
dark: highlightBorderColorDark,
}
: undefined,
isDarkModeEnabled: isDarkMode,
roundness,
cardArrangement: {
linkSurveys: linkSurveysCardArrangement,
inAppSurveys: inAppSurveysCardArrangement,
},
},
});
toast.success("Styling updated successfully.");
router.refresh();
};
return (
<div className="flex">
{/* Styling settings */}
<div className="w-1/2 pr-6">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<Switch
checked={unifiedStyling}
onCheckedChange={(value) => {
setUnifiedStyling(value);
}}
/>
<div className="flex flex-col">
<h3 className="text-base font-semibold">Enable unified styling</h3>
<p className="text-sm text-slate-800">Set base styles for all surveys below</p>
</div>
</div>
<div className="flex items-center gap-6">
<Switch
checked={allowStyleOverwrite}
onCheckedChange={(value) => {
setAllowStyleOverwrite(value);
}}
disabled={!unifiedStyling}
/>
<div className="flex flex-col">
<h3 className="text-base font-semibold">Allow overwriting styles</h3>
<p className="text-sm text-slate-800">
Activate if you want some surveys to be styled differently
</p>
</div>
</div>
</div>
<ColorSelectorWithLabel
label="Brand color"
color={brandColor}
setColor={setBrandColor}
description="Change the text color of the survey questions."
disabled
/>
<ColorSelectorWithLabel
label="Question color"
color={questionColor}
setColor={setQuestionColor}
description="Change the text color of the survey questions."
/>
<ColorSelectorWithLabel
label="Input color"
color={inputColor}
setColor={setInputColor}
description="Change the text color of the survey questions."
/>
<ColorSelectorWithLabel
label="Input border color"
color={inputBorderColor}
setColor={setInputBorderColor}
description="Change the text color of the survey questions."
/>
<ColorSelectorWithLabel
label="Card background color"
color={cardBackgroundColor}
setColor={setCardBackgroundColor}
description="Change the text color of the survey questions."
/>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-6">
<Switch
checked={allowHighlightBorder}
onCheckedChange={(value) => {
setAllowHighlightBorder(value);
}}
disabled={!unifiedStyling}
/>
<div className="flex flex-col">
<h3 className="text-base font-semibold">Add highlight border</h3>
<p className="text-sm text-slate-800">Add on outer border to your survey card</p>
</div>
</div>
{allowHighlightBorder && (
<ColorPicker
color={highlightBorderColor}
onChange={setHighlightBorderColor}
containerClass="my-0"
/>
)}
</div>
<DarkModeColors
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
brandColor={brandColorDark}
cardBackgroundColor={cardBackgroundColorDark}
highlightBorderColor={highlightBorderColorDark}
inputBorderColor={inputBorderColorDark}
inputColor={inputColorDark}
questionColor={questionColorDark}
setBrandColor={setBrandColorDark}
setCardBackgroundColor={setCardBackgroundColorDark}
setHighlighBorderColor={setHighlightBorderColorDark}
setInputBorderColor={setInputBorderColorDark}
setInputColor={setInputColorDark}
setQuestionColor={setQuestionColorDark}
/>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<h3 className="text-base font-semibold text-slate-900">Roundness</h3>
<p className="text-sm text-slate-800">Change the border radius of the card and the inputs.</p>
</div>
<Slider
value={[roundness]}
max={16}
onValueChange={(value) => setRoundness(value[0])}
disabled={!unifiedStyling}
/>
</div>
<CardArrangement
activeCardArrangement={linkSurveysCardArrangement}
surveyType="link"
setActiveCardArrangement={setLinkSurveysCardArrangement}
/>
<CardArrangement
activeCardArrangement={inAppSurveysCardArrangement}
surveyType="web"
setActiveCardArrangement={setInAppSurveysCardArrangement}
/>
</div>
<div className="mt-8 flex items-center justify-end gap-2">
<Button variant="minimal" className="flex items-center gap-2">
Reset
<RotateCcwIcon className="h-4 w-4" />
</Button>
<Button variant="darkCTA" onClick={onSave}>
Save changes
</Button>
</div>
</div>
{/* Survey Preview */}
<div className="w-1/2 bg-slate-100 pt-4">
<div className="h-full max-h-[800px]">
<UnifiedStylingPreviewSurvey
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
survey={previewSurvey as TSurvey}
product={product}
/>
</div>
</div>
</div>
);
};
export default UnifiedStyling;

View File

@@ -0,0 +1,261 @@
"use client";
import Modal from "@/app/(app)/environments/[environmentId]/surveys/components/Modal";
import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/24/solid";
import { Variants, motion } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/product";
import { TStyling } from "@formbricks/types/styling";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { SurveyInline } from "@formbricks/ui/Survey";
interface UnifiedStylingPreviewSurveyProps {
survey: TSurvey;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
product: TProduct;
}
let surveyNameTemp;
const previewParentContainerVariant: Variants = {
expanded: {
position: "fixed",
height: "100%",
width: "100%",
backgroundColor: "rgba(0, 0, 0, 0.4)",
backdropFilter: "blur(15px)",
left: 0,
top: 0,
zIndex: 1040,
transition: {
ease: "easeIn",
duration: 0.001,
},
},
shrink: {
display: "none",
position: "fixed",
backgroundColor: "rgba(0, 0, 0, 0.0)",
backdropFilter: "blur(0px)",
transition: {
duration: 0,
},
zIndex: -1,
},
};
export default function UnifiedStylingPreviewSurvey({
setActiveQuestionId,
activeQuestionId,
survey,
product,
}: UnifiedStylingPreviewSurveyProps) {
const [isModalOpen, setIsModalOpen] = useState(true);
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
const [previewPosition, setPreviewPosition] = useState("relative");
const ContentRef = useRef<HTMLDivElement | null>(null);
const [shrink, setshrink] = useState(false);
const [previewType, setPreviewType] = useState<"link" | "web">("link");
const { productOverwrites } = survey || {};
const previewScreenVariants: Variants = {
expanded: {
right: "5%",
bottom: "10%",
top: "12%",
width: "40%",
position: "fixed",
height: "80%",
zIndex: 1050,
boxShadow: "0px 4px 5px 4px rgba(169, 169, 169, 0.25)",
transition: {
ease: "easeInOut",
duration: shrink ? 0.3 : 0,
},
},
expanded_with_fixed_positioning: {
zIndex: 1050,
position: "fixed",
top: "5%",
right: "5%",
bottom: "10%",
width: "90%",
height: "90%",
transition: {
ease: "easeOut",
duration: 0.4,
},
},
shrink: {
display: "relative",
width: ["83.33%"],
height: ["95%"],
},
};
const { placement: surveyPlacement } = productOverwrites || {};
const placement = surveyPlacement || product.placement;
const highlightBorderColor = product.styling?.highlightBorderColor?.light;
const styling: TStyling = useMemo(() => {
if (product.styling) {
return product.styling;
}
return {
unifiedStyling: true,
allowStyleOverwrite: true,
brandColor: {
light: product.brandColor || "#64748b",
},
};
}, [product.brandColor, product.styling]);
// this useEffect is fo refreshing the survey preview only if user is switching between templates on survey templates page and hence we are checking for survey.id === "someUniqeId1" which is a common Id for all templates
useEffect(() => {
if (survey.name !== surveyNameTemp && survey.id === "someUniqueId1") {
resetQuestionProgress();
surveyNameTemp = survey.name;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey]);
useEffect(() => {
if (previewType === "web") {
setIsModalOpen(true);
}
}, [previewType]);
function resetQuestionProgress() {
setActiveQuestionId(survey?.questions[0]?.id);
}
const onFileUpload = async (file: File) => file.name;
return (
<div className="flex h-full w-full flex-col items-center justify-items-center">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
layout
variants={previewScreenVariants}
animate={
isFullScreenPreview
? previewPosition === "relative"
? "expanded"
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{previewType === "web" ? "Your web app" : "Preview"}</p>
<div className="flex items-center">
{isFullScreenPreview ? (
<ArrowsPointingInIcon
className="mr-2 h-4 w-4 cursor-pointer"
onClick={() => {
setshrink(true);
setPreviewPosition("relative");
setTimeout(() => setIsFullScreenPreview(false), 300);
}}
/>
) : (
<ArrowsPointingOutIcon
className="mr-2 h-4 w-4 cursor-pointer"
onClick={() => {
setshrink(false);
setIsFullScreenPreview(true);
setTimeout(() => setPreviewPosition("fixed"), 300);
}}
/>
)}
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</div>
</div>
</div>
{previewType === "web" ? (
<Modal
isOpen
placement={placement}
highlightBorderColor={highlightBorderColor}
previewMode="desktop"
borderRadius={styling.roundness ?? 12}>
<SurveyInline
survey={survey}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
styling={styling}
/>
</Modal>
) : (
<MediaBackground survey={survey} ContentRef={ContentRef} isEditorView>
<div className="z-0 w-full max-w-md rounded-lg p-4">
<SurveyInline
survey={survey}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
responseCount={42}
styling={styling}
/>
</div>
</MediaBackground>
)}
</div>
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<div
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1`}
onClick={() => setPreviewType("link")}>
Link survey
</div>
<div
className={`${previewType === "web" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1`}
onClick={() => setPreviewType("web")}>
App survey
</div>
</div>
</div>
);
}
function ResetProgressButton({ resetQuestionProgress }) {
return (
<Button
variant="minimal"
className="py-0.2 mr-2 bg-white px-2 font-sans text-sm text-slate-500"
onClick={resetQuestionProgress}>
Restart
<ArrowPathRoundedSquareIcon className="ml-2 h-4 w-4" />
</Button>
);
}

View File

@@ -18,6 +18,7 @@ import { EditBrandColor } from "./components/EditBrandColor";
import { EditFormbricksBranding } from "./components/EditBranding";
import { EditHighlightBorder } from "./components/EditHighlightBorder";
import { EditPlacement } from "./components/EditPlacement";
import UnifiedStyling from "./components/UnifiedStyling";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const [session, team, product] = await Promise.all([
@@ -50,19 +51,24 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
return (
<div>
<SettingsTitle title="Look & Feel" />
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<SettingsCard
title="Unified Styling"
description="Set styling for ALL surveys in this project. You can still overwrite these styles in the survey editor.">
<UnifiedStyling product={product} />
</SettingsCard>
{/* <SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<EditBrandColor
product={product}
isBrandColorDisabled={isBrandColorEditDisabled}
environmentId={params.environmentId}
/>
</SettingsCard>
</SettingsCard> */}
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<EditPlacement product={product} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
{/* <SettingsCard
noPadding
title="Highlight Border"
description="Make sure your users notice the survey you display">
@@ -71,7 +77,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
defaultBrandColor={DEFAULT_BRAND_COLOR}
environmentId={params.environmentId}
/>
</SettingsCard>
</SettingsCard> */}
<SettingsCard
title="Formbricks Branding"
description="We love your support but understand if you toggle it off.">

View File

@@ -79,7 +79,7 @@ export default function AddMemberModal({
<div>
<AddMemberRole control={control} canDoRoleManagement={canDoRoleManagement} />
{!canDoRoleManagement &&
(!isFormbricksCloud ? (
(isFormbricksCloud ? (
<UpgradePlanNotice
message="To manage access roles,"
url={`/environments/${environmentId}/settings/billing`}

View File

@@ -111,7 +111,7 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
onClick={() => {
handleShareInvite();
}}
id="shareInviteButton">
className="shareInviteButton">
<ShareIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>

View File

@@ -61,7 +61,7 @@ function DeleteAccountModal({ setOpen, open, session, IS_FORMBRICKS_CLOUD }: Del
await signOut({ redirect: true });
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
} else {
await signOut();
await signOut({ callbackUrl: "/auth/login" });
}
} catch (error) {
toast.error("Something went wrong");

View File

@@ -94,7 +94,7 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
<div className="flex max-w-full flex-col items-center justify-center space-x-2 lg:flex-row">
<div
ref={linkTextRef}
className="mt-2 max-w-[70%] overflow-hidden rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-slate-800"
className="mt-2 max-w-[80%] overflow-hidden rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-slate-800"
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
onClick={() => handleTextSelection()}>
{surveyUrl}

View File

@@ -314,12 +314,6 @@ export default function SurveyMenuBar({
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary`);
} else {
router.push(`/environments/${environment.id}/surveys`);
}
}
} catch (e) {
console.error(e);

View File

@@ -10,12 +10,14 @@ export default function Modal({
placement,
previewMode,
highlightBorderColor,
borderRadius,
}: {
children: ReactNode;
isOpen: boolean;
placement: TPlacement;
previewMode: string;
highlightBorderColor: string | null | undefined;
borderRadius?: number;
}) {
const [show, setShow] = useState(false);
const modalRef = useRef<HTMLDivElement | null>(null);
@@ -99,10 +101,17 @@ export default function Modal({
: "";
return (
<div aria-live="assertive" className="relative h-full w-full bg-slate-300">
<div aria-live="assertive" className="relative h-full w-full overflow-hidden bg-slate-300">
<div
ref={modalRef}
style={{ ...highlightBorderColorStyle, ...scalingClasses }}
style={{
...highlightBorderColorStyle,
...scalingClasses,
...(borderRadius && {
borderRadius: `${borderRadius}px`,
}),
}}
className={cn(
"no-scrollbar pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out ",
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full",

View File

@@ -11,11 +11,12 @@ import {
DevicePhoneMobileIcon,
} from "@heroicons/react/24/solid";
import { Variants, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { TEnvironment } from "@formbricks/types/environment";
import type { TProduct } from "@formbricks/types/product";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TStyling } from "@formbricks/types/styling";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { SurveyInline } from "@formbricks/ui/Survey";
@@ -120,10 +121,24 @@ export default function PreviewSurvey({
placement: surveyPlacement,
} = productOverwrites || {};
const brandColor = surveyBrandColor || product.brandColor;
// const brandColor = surveyBrandColor || product.brandColor;
const placement = surveyPlacement || product.placement;
const highlightBorderColor = surveyHighlightBorderColor || product.highlightBorderColor;
const styling: TStyling = useMemo(() => {
if (product.styling) {
return product.styling;
}
return {
unifiedStyling: true,
allowStyleOverwrite: true,
brandColor: {
light: product.brandColor || "#64748b",
},
};
}, [product.brandColor, product.styling]);
useEffect(() => {
// close modal if there are no questions left
if (survey.type === "web" && !survey.thankYouCard.enabled) {
@@ -207,25 +222,27 @@ export default function PreviewSurvey({
previewMode="mobile">
<SurveyInline
survey={survey}
brandColor={brandColor}
// brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
styling={styling}
/>
</Modal>
) : (
<div className="px-4">
<div className="no-scrollbar z-10 max-h-[500px] w-full max-w-md overflow-y-auto rounded-lg border border-transparent">
<div className="w-full px-3">
<div className="no-scrollbar z-10 w-full max-w-md overflow-y-auto rounded-lg border border-transparent">
<SurveyInline
survey={survey}
brandColor={brandColor}
// brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
onFileUpload={onFileUpload}
responseCount={42}
styling={styling}
/>
</div>
</div>
@@ -277,12 +294,13 @@ export default function PreviewSurvey({
previewMode="desktop">
<SurveyInline
survey={survey}
brandColor={brandColor}
// brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
styling={styling}
/>
</Modal>
) : (
@@ -290,13 +308,14 @@ export default function PreviewSurvey({
<div className="z-0 w-full max-w-md rounded-lg p-4">
<SurveyInline
survey={survey}
brandColor={brandColor}
// brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
responseCount={42}
styling={styling}
/>
</div>
</MediaBackground>

View File

@@ -27,6 +27,7 @@ export default function SurveyStarter({
}) {
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
const router = useRouter();
const newSurveyFromTemplate = async (template: TTemplate) => {
setIsCreateSurveyLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";

View File

@@ -2,8 +2,12 @@ import { createId } from "@paralleldrive/cuid2";
import {
TSurvey,
TSurveyCTAQuestion,
TSurveyDisplayOption,
TSurveyHiddenFields,
TSurveyQuestionType,
TSurveyStatus,
TSurveyType,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys";
import { TTemplate } from "@formbricks/types/templates";
@@ -558,7 +562,6 @@ export const templates: TTemplate[] = [
},
{
name: "Churn Survey",
category: "Increase Revenue",
objectives: ["sharpen_marketing_messaging", "improve_user_retention"],
description: "Find out why people cancel their subscriptions. These insights are pure gold!",
@@ -1125,7 +1128,7 @@ export const templates: TTemplate[] = [
},
},
{
name: "Changing subscription experience",
name: "Changing Subscription Experience",
category: "Increase Revenue",
objectives: ["increase_conversion", "improve_user_retention"],
@@ -1519,9 +1522,9 @@ export const templates: TTemplate[] = [
category: "Customer Success",
objectives: ["support_sales"],
description: "Measure the Net Promoter Score of your product.",
description: "Measure the Net Promoter Score of your product or service.",
preset: {
name: "{{productName}} NPS",
name: "NPS Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
@@ -1546,7 +1549,6 @@ export const templates: TTemplate[] = [
},
{
name: "Customer Satisfaction Score (CSAT)",
category: "Customer Success",
objectives: ["support_sales"],
description: "Measure the Customer Satisfaction Score of your product.",
@@ -1589,7 +1591,95 @@ export const templates: TTemplate[] = [
},
},
{
name: "Identify upsell opportunities",
name: "Collect Feedback",
category: "Product Experience",
objectives: ["increase_user_adoption", "improve_user_retention"],
description: "Gather comprehensive feedback on your product or service.",
preset: {
name: "Feedback Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
type: TSurveyQuestionType.Rating,
logic: [{ value: "3", condition: "lessEqual", destination: "dlpa0371pe7rphmggy2sgbap" }],
range: 5,
scale: "star",
headline: "How do you rate your overall experience?",
required: true,
subheader: "Don't worry, be honest.",
lowerLabel: "Not good",
upperLabel: "Very good",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
logic: [{ condition: "submitted", destination: "gwo0fq5kug13e83fcour4n1w" }],
headline: "Lovely! What did you like about it?",
required: true,
longAnswer: true,
placeholder: "Type your answer here...",
inputType: "text",
},
{
id: "dlpa0371pe7rphmggy2sgbap",
type: TSurveyQuestionType.OpenText,
headline: "Thanks for sharing! What did you not like?",
required: true,
longAnswer: true,
placeholder: "Type your answer here...",
inputType: "text",
},
{
id: "gwo0fq5kug13e83fcour4n1w",
type: TSurveyQuestionType.Rating,
range: 5,
scale: "smiley",
headline: "How do you rate our communication?",
required: true,
lowerLabel: "Not good",
upperLabel: "Very good",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "Anything else you'd like to share with our team?",
required: false,
longAnswer: true,
placeholder: "Type your answer here...",
inputType: "text",
},
{
id: "sjbaghd1bi59pkjun2c97kw9",
type: TSurveyQuestionType.MultipleChoiceSingle,
logic: [],
choices: [
{ id: createId(), label: "Google" },
{ id: createId(), label: "Social Media" },
{ id: createId(), label: "Friends" },
{ id: createId(), label: "Podcast" },
{ id: "other", label: "Other" },
],
headline: "How did you hear about us?",
required: true,
shuffleOption: "none",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "Lastly, we'd love to respond to your feedback. Please share your email:",
required: false,
inputType: "email",
longAnswer: false,
placeholder: "example@email.com",
},
],
thankYouCard: thankYouCardDefault,
hiddenFields: hiddenFieldsDefault,
},
},
{
name: "Identify Upsell Opportunities",
category: "Increase Revenue",
objectives: ["support_sales", "sharpen_marketing_messaging"],
@@ -2554,3 +2644,26 @@ export const minimalSurvey: TSurvey = {
resultShareKey: null,
segment: null,
};
export const getFirstSurvey = (webAppUrl: string) => ({
...customSurvey.preset,
questions: customSurvey.preset.questions.map(
(question) =>
({
...question,
type: TSurveyQuestionType.CTA,
headline: "You did it 🎉",
html: "You're all set up. Create your own survey to gather exactly the feedback you need :)",
buttonLabel: "Create survey",
buttonExternal: true,
imageUrl: `${webAppUrl}/onboarding/meme.png`,
}) as TSurveyCTAQuestion
),
name: "Example survey",
type: "web" as TSurveyType,
autoComplete: 2,
triggers: ["New Session"],
status: "inProgress" as TSurveyStatus,
displayOption: "respondMultiple" as TSurveyDisplayOption,
recontactDays: 0,
});

View File

@@ -2,14 +2,111 @@
import { getServerSession } from "next-auth";
import { hasTeamAuthority } from "@formbricks/lib/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { canUserAccessProduct, verifyUserRoleAccess } from "@formbricks/lib/product/auth";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { inviteUser } from "@formbricks/lib/invite/service";
import { canUserAccessProduct } from "@formbricks/lib/product/auth";
import { getProduct, updateProduct } from "@formbricks/lib/product/service";
import { createSurvey } from "@formbricks/lib/survey/service";
import { verifyUserRoleAccess } from "@formbricks/lib/team/auth";
import { updateUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProductUpdateInput } from "@formbricks/types/product";
import { TSurveyInput, TSurveyType } from "@formbricks/types/surveys";
import { TTemplate } from "@formbricks/types/templates";
import { TUserUpdateInput } from "@formbricks/types/user";
export const inviteTeamMateAction = async (
teamId: string,
email: string,
role: TMembershipRole,
inviteMessage: string
) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(teamId, session.user.id);
if (!hasCreateOrUpdateMembersAccess) {
throw new AuthenticationError("Not authorized");
}
const invite = await inviteUser({
teamId,
currentUser: { id: session.user.id, name: session.user.name },
invitee: {
email,
name: "",
role,
},
isOnboardingInvite: true,
inviteMessage: inviteMessage,
});
return invite;
};
export const finishOnboardingAction = async () => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const updatedProfile = { onboardingCompleted: true };
return await updateUser(session.user.id, updatedProfile);
};
export async function createSurveyAction(environmentId: string, surveyBody: TSurveyInput) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createSurvey(environmentId, surveyBody);
}
export async function fetchEnvironment(id: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
return await getEnvironment(id);
}
export const createSurveyFromTemplate = async (template: TTemplate, environmentId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const userHasAccess = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!userHasAccess) throw new AuthorizationError("Not authorized");
// Set common survey properties
const userId = session.user.id;
// Construct survey input based on the pathway
const surveyInput = {
...template.preset,
type: "link" as TSurveyType,
autoComplete: undefined,
createdBy: userId,
};
// Create and return the new survey
return await createSurvey(environmentId, surveyInput);
};
export async function updateUserAction(updatedUser: TUserUpdateInput) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

View File

@@ -1,76 +0,0 @@
"use client";
import type { Session } from "next-auth";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { Button } from "@formbricks/ui/Button";
type Greeting = {
next: () => void;
skip: () => void;
name: string;
session: Session | null;
};
const Greeting: React.FC<Greeting> = ({ next, skip, name, session }) => {
const legacyUser = !session ? false : new Date(session?.user?.createdAt) < new Date("2023-05-03T00:00:00"); // if user is created before onboarding deployment
const buttonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
next();
}
};
const button = buttonRef.current;
if (button) {
button.focus();
button.addEventListener("keydown", handleKeyDown);
}
return () => {
if (button) {
button.removeEventListener("keydown", handleKeyDown);
}
};
}, [next]);
return (
<div className="flex h-full w-full max-w-xl flex-col justify-around gap-8 px-8">
<div className="mt-auto h-1/2 space-y-6">
<div className="px-4">
<h1 className="pb-4 text-4xl font-bold text-slate-900">
👋 Hi, {name}! <br />
{legacyUser ? "Welcome back!" : "Welcome to Formbricks!"}
</h1>
<p className="text-xl text-slate-500">
{legacyUser ? "Let's customize your account." : "Let's finish setting up your account."}
</p>
</div>
<div className="flex justify-between">
<Button size="lg" variant="minimal" onClick={skip}>
I&apos;ll do it later
</Button>
<Button size="lg" variant="darkCTA" onClick={next} ref={buttonRef} tabIndex={0}>
Begin (1 min)
</Button>
</div>
</div>
<div className="flex items-center justify-center text-xs text-slate-400">
<div className="pb-12 pt-8 text-center">
<p>Your answers will help us improve your experience and help others like you.</p>
<p>
<Link href="https://formbricks.com/privacy-policy" target="_blank" className="underline">
Click here
</Link>{" "}
to learn how we handle your data.
</p>
</div>
</div>
</div>
);
};
export default Greeting;

View File

@@ -1,150 +0,0 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
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 { handleTabNavigation } from "../utils";
type ObjectiveProps = {
next: () => void;
skip: () => void;
formbricksResponseId?: string;
user: TUser;
};
type ObjectiveChoice = {
label: string;
id: TUserObjective;
};
const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId, user }) => {
const objectives: Array<ObjectiveChoice> = [
{ 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<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const handleNextClick = async () => {
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.label,
},
true
);
if (!res.ok) {
console.error("Error updating response", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label htmlFor="choices" className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What do you want to achieve?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
We have 85+ templates, help us select the best for your need.
</label>
<div className="mt-4">
<fieldset id="choices" aria-label="What do you want to achieve?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{objectives.map((choice) => (
<label
key={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-100 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
type="radio"
id={choice.id}
value={choice.label}
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
</span>
</span>
</label>
))}
</div>
</fieldset>
</div>
</div>
<div className="mb-24 flex justify-between">
<Button size="lg" className="text-slate-500" variant="minimal" onClick={skip} id="objective-skip">
Skip
</Button>
<Button
size="lg"
variant="darkCTA"
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="objective-next">
Next
</Button>
</div>
</div>
);
};
export default Objective;

View File

@@ -1,109 +0,0 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { TUser } from "@formbricks/types/user";
import { Logo } from "@formbricks/ui/Logo";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import Greeting from "./Greeting";
import Objective from "./Objective";
import Product from "./Product";
import Role from "./Role";
const MAX_STEPS = 6;
interface OnboardingProps {
session: Session;
environmentId: string;
user: TUser;
product: TProduct;
}
export default function Onboarding({ session, environmentId, user, product }: OnboardingProps) {
const [formbricksResponseId, setFormbricksResponseId] = useState<string | undefined>();
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const percent = useMemo(() => {
return currentStep / MAX_STEPS;
}, [currentStep]);
const skipStep = () => {
setCurrentStep(currentStep + 1);
};
const doLater = async () => {
setCurrentStep(4);
};
const next = () => {
if (currentStep < MAX_STEPS) {
setCurrentStep((value) => value + 1);
return;
}
};
const done = async () => {
setIsLoading(true);
try {
const updatedProfile = { onboardingCompleted: true };
await updateUserAction(updatedProfile);
if (environmentId) {
router.push(`/environments/${environmentId}/surveys`);
return;
}
} catch (e) {
toast.error("An error occured saving your settings.");
setIsLoading(false);
console.error(e);
}
};
return (
<div className="flex h-full w-full flex-col bg-slate-50">
<div className="mx-auto grid w-full max-w-7xl grid-cols-6 items-center pt-8">
<div className="col-span-2">
<Logo className="ml-4 w-1/2" />
</div>
<div className="col-span-2 flex items-center justify-center gap-8">
<div className="relative grow overflow-hidden rounded-full bg-slate-200">
<ProgressBar progress={percent} barColor="bg-brand-dark" height={2} />
</div>
<div className="grow-0 text-xs font-semibold text-slate-700">
{currentStep < 5 ? <>{Math.floor(percent * 100)}% complete</> : <>Almost there!</>}
</div>
</div>
<div className="col-span-2" />
</div>
<div className="flex grow items-center justify-center">
{currentStep === 1 && (
<Greeting next={next} skip={doLater} name={user.name ? user.name : ""} session={session} />
)}
{currentStep === 2 && (
<Role
next={next}
skip={skipStep}
setFormbricksResponseId={setFormbricksResponseId}
session={session}
/>
)}
{currentStep === 3 && (
<Objective next={next} skip={skipStep} formbricksResponseId={formbricksResponseId} user={user} />
)}
{currentStep === 4 && (
<Product done={done} environmentId={environmentId} isLoading={isLoading} product={product} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
// Filename: IntroSection.tsx
import React from "react";
type OnboardingTitleProps = {
title: string;
subtitle: string;
};
const OnboardingTitle: React.FC<OnboardingTitleProps> = ({ title, subtitle }) => {
return (
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800">{title}</p>
<p className="text-sm text-slate-500">{subtitle}</p>
</div>
);
};
export default OnboardingTitle;

View File

@@ -0,0 +1,69 @@
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import InappMockup from "@/images/onboarding-in-app-survey.png";
import LinkMockup from "@/images/onboarding-link-survey.webp";
import Image from "next/image";
import { OptionCard } from "@formbricks/ui/OptionCard";
interface PathwaySelectProps {
setSelectedPathway: (pathway: "link" | "in-app" | null) => void;
setCurrentStep: (currentStep: number) => void;
isFormbricksCloud: boolean;
}
type PathwayOptionType = "link" | "in-app";
export default function PathwaySelect({
setSelectedPathway,
setCurrentStep,
isFormbricksCloud,
}: PathwaySelectProps) {
const handleSelect = async (pathway: PathwayOptionType) => {
if (pathway === "link") {
localStorage.setItem("onboardingPathway", "link");
if (isFormbricksCloud) {
setCurrentStep(2);
localStorage.setItem("onboardingCurrentStep", "2");
} else {
setCurrentStep(5);
localStorage.setItem("onboardingCurrentStep", "5");
}
} else {
localStorage.setItem("onboardingPathway", "in-app");
setCurrentStep(2);
localStorage.setItem("onboardingCurrentStep", "2");
}
setSelectedPathway(pathway);
};
return (
<div className="space-y-16 text-center">
<OnboardingTitle
title="How would you like to start?"
subtitle="You can always use all types of surveys later on."
/>
<div className="flex space-x-8">
<OptionCard
cssId="onboarding-link-survey-card"
size="lg"
title="Link Surveys"
description="Create a new survey and share a link."
onSelect={() => {
handleSelect("link");
}}>
<Image src={LinkMockup} alt="" height={350} />
</OptionCard>
<OptionCard
cssId="onboarding-inapp-survey-card"
size="lg"
title="In-app Surveys"
description="Run a survey on a website or in-app."
onSelect={() => {
handleSelect("in-app");
}}>
<Image src={InappMockup} alt="" height={350} />
</OptionCard>
</div>
</div>
);
}

View File

@@ -1,174 +0,0 @@
"use client";
import { updateProductAction } from "@/app/(app)/onboarding/actions";
import { isLight } from "@/app/lib/utils";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
type Product = {
done: () => void;
environmentId: string;
isLoading: boolean;
product: TProduct;
};
const Product: React.FC<Product> = ({ done, isLoading, environmentId, product }) => {
const [loading, setLoading] = useState(true);
const [name, setName] = useState("");
const [color, setColor] = useState("##4748b");
const handleNameChange = (event) => {
setName(event.target.value);
};
const handleColorChange = (color) => {
setColor(color);
};
useEffect(() => {
if (!product) {
return;
} else if (product && product.name !== "My Product") {
done(); // when product already exists, skip product step entirely
} else {
if (product) {
setColor(product.brandColor);
}
setLoading(false);
}
}, [product, done]);
const dummyChoices = ["❤️ Love it!"];
const handleDoneClick = async () => {
if (!name || !environmentId) {
return;
}
try {
await updateProductAction(product.id, { name, brandColor: color });
} catch (e) {
toast.error("An error occured saving your settings");
console.error(e);
}
done();
};
const handleLaterClick = async () => {
done();
};
if (loading) {
return <LoadingSpinner />;
}
if (!product) {
return <ErrorComponent />;
}
const buttonStyle = {
backgroundColor: color,
color: isLight(color) ? "black" : "white",
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
Create your team&apos;s product.
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
You can always change these settings later.
</label>
<div className="mt-6 flex flex-col gap-2">
<div className="pb-2">
<div className="flex justify-between">
<Label htmlFor="product">Your product name</Label>
<span className="text-xs text-slate-500">Required</span>
</div>
<div className="mt-2">
<Input
id="product"
type="text"
placeholder="e.g. Formbricks"
value={name}
onChange={handleNameChange}
aria-label="Your product name"
/>
</div>
</div>
<div>
<Label htmlFor="color">Primary color</Label>
<div className="mt-2">
<ColorPicker color={color} onChange={handleColorChange} />
</div>
</div>
<div className="relative flex cursor-not-allowed flex-col items-center gap-4 rounded-md border border-slate-300 px-16 py-8">
<div
className="absolute left-0 right-0 top-0 h-full w-full opacity-10"
style={{ backgroundColor: color }}
/>
<p className="text-xs text-slate-500">This is what your survey will look like:</p>
<div className="relative w-full max-w-sm cursor-not-allowed rounded-lg bg-white px-4 py-6 shadow-lg ring-1 ring-black ring-opacity-5 sm:p-6">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
How do you like {name || "Formbricks"}
</label>
<div className="mt-4">
<fieldset>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{dummyChoices.map((choice) => (
<label
key={choice}
className="relative z-10 flex flex-col rounded-md border border-slate-400 bg-slate-50 p-4 hover:bg-slate-50 focus:outline-none">
<span className="flex items-center text-sm">
<input
checked
readOnly
type="radio"
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
style={{ borderColor: "brandColor", color: "brandColor" }}
/>
<span className="ml-3 font-medium">{choice}</span>
</span>
</label>
))}
</div>
</fieldset>
</div>
<div className="mt-4 flex w-full justify-end">
<Button className="pointer-events-none" style={buttonStyle}>
Next
</Button>
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end">
<Button size="lg" className="mr-2" variant="minimal" id="product-skip" onClick={handleLaterClick}>
I&apos;ll do it later
</Button>
<Button
size="lg"
variant="darkCTA"
loading={isLoading}
disabled={!name || !environmentId}
onClick={handleDoneClick}>
{isLoading ? "Getting ready..." : "Done"}
</Button>
</div>
</div>
);
};
export default Product;

View File

@@ -0,0 +1,22 @@
import { Logo } from "@formbricks/ui/Logo";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface OnboardingHeaderProps {
progress: number;
}
export function OnboardingHeader({ progress }: OnboardingHeaderProps) {
return (
<div className="sticky z-50 mt-6 grid w-11/12 max-w-6xl grid-cols-6 items-center rounded-xl border border-slate-200 bg-white px-6 py-3">
<div className="col-span-2">
<Logo className="ml-4 w-1/2" />
</div>
<div className="col-span-1" />
<div className="col-span-3 flex items-center justify-center gap-8">
<div className="relative grow overflow-hidden rounded-full bg-slate-200">
<ProgressBar progress={progress / 100} barColor="bg-brand-dark" height={2} />
</div>
<span className="text-sm text-slate-800">{progress}% complete</span>
</div>
</div>
);
}

View File

@@ -1,146 +0,0 @@
"use client";
import { updateUserAction } from "@/app/(app)/onboarding/actions";
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 { handleTabNavigation } from "../utils";
type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: string) => void;
session: Session;
};
type RoleChoice = {
label: string;
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, session }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const roles: Array<RoleChoice> = [
{ 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 handleNextClick = async () => {
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.label,
});
if (res.ok) {
const response = res.data;
setFormbricksResponseId(response.id);
} else {
console.error("Error sending response to Formbricks", res.error);
}
}
next();
}
}
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label htmlFor="choices" className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What is your role?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
Make your Formbricks experience more personalised.
</label>
<div className="mt-4">
<fieldset id="choices" aria-label="What is your role?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{roles.map((choice) => (
<label
key={choice.id}
htmlFor={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 hover:bg-slate-100 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
type="radio"
id={choice.id}
value={choice.label}
name="role"
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}
</span>
</span>
</label>
))}
</div>
</fieldset>
</div>
</div>
<div className="mb-24 flex justify-between">
<Button size="lg" className="text-slate-500" variant="minimal" onClick={skip} id="role-skip">
Skip
</Button>
<Button
size="lg"
variant="darkCTA"
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="role-next">
Next
</Button>
</div>
</div>
);
};
export default Role;

View File

@@ -0,0 +1,152 @@
"use client";
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import Dance from "@/images/onboarding-dance.gif";
import Lost from "@/images/onboarding-lost.gif";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { Button } from "@formbricks/ui/Button";
import { fetchEnvironment, finishOnboardingAction } from "../../actions";
import SetupInstructionsOnboarding from "./SetupInstructions";
const goToProduct = async (router) => {
if (typeof localStorage !== undefined) {
localStorage.removeItem("onboardingPathway");
localStorage.removeItem("onboardingCurrentStep");
}
await finishOnboardingAction();
router.push("/");
};
const goToTeamInvitePage = async () => {
localStorage.setItem("onboardingCurrentStep", "5");
};
// Custom hook for visibility change logic
const useVisibilityChange = (environment, setLocalEnvironment) => {
useEffect(() => {
const handleVisibilityChange = async () => {
if (document.visibilityState === "visible") {
const refetchedEnvironment = await fetchEnvironment(environment.id);
if (!refetchedEnvironment) return;
setLocalEnvironment(refetchedEnvironment);
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [environment, setLocalEnvironment]);
};
const ConnectedState = ({ goToProduct }) => {
const [isLoading, setIsLoading] = useState(false);
return (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle title="We are connected!" subtitle="From now on it's a piece of cake 🍰" />
<div className="w-full space-y-8 rounded-lg border border-emerald-300 bg-emerald-50 p-8 text-center">
<Image src={Dance} alt="Dance" className="rounded-lg" />
<p className="text-lg font-semibold text-emerald-900">Connection successful </p>
</div>
<div className="mt-4 text-right">
<Button
id="onboarding-inapp-connect-connection-successful"
variant="minimal"
loading={isLoading}
onClick={() => {
setIsLoading(true);
goToProduct();
}}>
Next <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
const NotConnectedState = ({ environment, webAppUrl, jsPackageVersion, goToTeamInvitePage }) => {
return (
<div className="group mb-8 w-full max-w-xl space-y-8">
<OnboardingTitle
title="Connect your app or website"
subtitle="It takes just a few minutes to set it up."
/>
<div className="flex w-full items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-12 py-3 text-slate-700">
Waiting for your signal...
<Image src={Lost} alt="lost" height={75} />
</div>
<div className="w-full border-b border-slate-200 " />
<SetupInstructionsOnboarding
environmentId={environment.id}
webAppUrl={webAppUrl}
jsPackageVersion={jsPackageVersion}
/>
<div className="flex justify-center">
<Button
id="onboarding-inapp-connect-not-sure-how-to-do-this"
className="opacity-0 transition-all delay-[3000ms] duration-500 ease-in-out group-hover:opacity-100"
variant="minimal"
onClick={goToTeamInvitePage}>
I am not sure how to do this
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
};
interface ConnectProps {
environment: TEnvironment;
webAppUrl: string;
jsPackageVersion: string;
setCurrentStep: (currentStep: number) => void;
}
export function ConnectWithFormbricks({
environment,
webAppUrl,
jsPackageVersion,
setCurrentStep,
}: ConnectProps) {
const router = useRouter();
const [localEnvironment, setLocalEnvironment] = useState(environment);
useVisibilityChange(environment, setLocalEnvironment);
useEffect(() => {
const fetchLatestEnvironmentOnFirstLoad = async () => {
const refetchedEnvironment = await fetchEnvironment(environment.id);
if (!refetchedEnvironment) return;
setLocalEnvironment(refetchedEnvironment);
};
fetchLatestEnvironmentOnFirstLoad();
}, [environment.id]);
return localEnvironment.widgetSetupCompleted ? (
<ConnectedState
goToProduct={() => {
goToProduct(router);
}}
/>
) : (
<NotConnectedState
jsPackageVersion={jsPackageVersion}
webAppUrl={webAppUrl}
environment={environment}
goToTeamInvitePage={() => {
setCurrentStep(5);
localStorage.setItem("onboardingCurrentStep", "5");
goToTeamInvitePage();
}}
/>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import OnboardingTitle from "@/app/(app)/onboarding/components/OnboardingTitle";
import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { TTeam } from "@formbricks/types/teams";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { finishOnboardingAction, inviteTeamMateAction } from "../../actions";
interface InviteTeamMateProps {
team: TTeam;
environmentId: string;
setCurrentStep: (currentStep: number) => void;
}
const DEFAULT_INVITE_MESSAGE =
"I'm looking into Formbricks to run targeted surveys. Can you help me set it up? 🙏";
const INITIAL_FORM_STATE = { email: "", inviteMessage: DEFAULT_INVITE_MESSAGE };
function isValidEmail(email) {
const regex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
return regex.test(email);
}
function InviteMessageInput({ value, onChange }) {
return (
<textarea
rows={5}
placeholder="engineering@acme.com"
className="focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
value={value}
onChange={onChange}
/>
);
}
export function InviteTeamMate({ team, environmentId, setCurrentStep }: InviteTeamMateProps) {
const [formState, setFormState] = useState(INITIAL_FORM_STATE);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleInputChange = (e, name) => {
const value = e.target.value;
setFormState({ ...formState, [name]: value });
};
const handleInvite = async () => {
if (!isValidEmail(formState.email)) {
toast.error("Invalid Email");
return;
}
try {
await inviteTeamMateAction(team.id, formState.email, "developer", formState.inviteMessage);
toast.success("Invite sent successful");
goToProduct();
} catch (error) {
toast.error(error.message || "An unexpected error occurred");
}
};
const goToProduct = async () => {
setIsLoading(true);
try {
if (typeof localStorage !== undefined) {
localStorage.removeItem("onboardingPathway");
localStorage.removeItem("onboardingCurrentStep");
}
await finishOnboardingAction();
router.push(`/environments/${environmentId}/surveys`);
} catch (error) {
toast.error("An error occurred saving your settings.");
console.error(error);
}
};
const goBackToConnectPage = () => {
setCurrentStep(4);
localStorage.setItem("onboardingCurrentStep", "4");
};
return (
<div className="group mb-8 w-full max-w-xl space-y-8">
<OnboardingTitle
title="Invite your team to help out"
subtitle="Ask your tech-savvy co-worker to finish the setup:"
/>
<div className="flex h-[65vh] flex-col justify-between">
<div className="space-y-4">
<Input
tabIndex={0}
placeholder="engineering@acme.com"
className="w-full bg-white"
value={formState.email}
onChange={(e) => handleInputChange(e, "email")}
/>
<InviteMessageInput
value={formState.inviteMessage}
onChange={(e) => handleInputChange(e, "inviteMessage")}
/>
<div className="flex w-full justify-between">
<Button id="onboarding-inapp-invite-back" variant="minimal" onClick={() => goBackToConnectPage()}>
Back
</Button>
<Button id="onboarding-inapp-invite-send-invite" variant="darkCTA" onClick={handleInvite}>
Invite
</Button>
</div>
</div>
<div className="mt-auto flex justify-center">
<Button
id="onboarding-inapp-invite-have-a-look-first"
className="opacity-0 transition-all delay-[3000ms] duration-500 ease-in-out group-hover:opacity-100"
variant="minimal"
onClick={goToProduct}
loading={isLoading}>
I want to have a look around first <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { IoLogoHtml5, IoLogoNpm } from "react-icons/io5";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import CodeBlock from "@formbricks/ui/CodeBlock";
const tabs = [
{ id: "html", label: "HTML", icon: <IoLogoHtml5 /> },
{ id: "npm", label: "NPM", icon: <IoLogoNpm /> },
];
interface SetupInstructionsOnboardingProps {
environmentId: string;
webAppUrl: string;
jsPackageVersion: string;
}
export default function SetupInstructionsOnboarding({
environmentId,
webAppUrl,
jsPackageVersion,
}: SetupInstructionsOnboardingProps) {
const [activeTab, setActiveId] = useState(tabs[0].id);
const htmlSnippet = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^${jsPackageVersion}/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`;
return (
<div>
<div className="flex h-14 w-full items-center justify-center rounded-md border border-slate-200 bg-white">
<nav className="flex h-full w-full items-center space-x-4 p-1.5" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
tab.id === activeTab
? " bg-slate-100 font-semibold text-slate-900"
: "text-slate-500 transition-all duration-300 hover:bg-slate-50 hover:text-slate-700",
"flex h-full w-full items-center justify-center rounded-md px-3 py-2 text-center text-sm font-medium"
)}
aria-current={tab.id === activeTab ? "page" : undefined}>
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
{tab.label}
</button>
))}
</nav>
</div>
<div className="">
{activeTab === "npm" ? (
<div className="prose prose-slate">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js --save
</CodeBlock>
<p className="text-sm text-slate-700">
Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
</p>
<CodeBlock
customEditorClass="!bg-white border border-slate-200"
language="js">{`import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
});
}`}</CodeBlock>
<Button
id="onboarding-inapp-connect-read-npm-docs"
className="mt-3"
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides"
target="_blank">
Read docs
</Button>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
Insert this code into the &lt;head&gt; tag of your website:
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{htmlSnippet}
</CodeBlock>
<div className="mt-4 space-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant="darkCTA"
onClick={() => {
navigator.clipboard.writeText(htmlSnippet);
toast.success("Copied to clipboard");
}}>
Copy code
</Button>
<Button
id="onboarding-inapp-connect-step-by-step-manual"
variant="secondary"
href="https://formbricks.com/docs/getting-started/framework-guides#html"
target="_blank">
Step by step manual
</Button>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -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<ObjectiveProps> = ({ formbricksResponseId, user, setCurrentStep }) => {
const objectives: Array<ObjectiveChoice> = [
{ 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<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const [otherValue, setOtherValue] = useState("");
const fieldsetRef = useRef<HTMLFieldSetElement>(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 (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle
title="What do you want to achieve?"
subtitle="We suggest templates based on your selection."
/>
<fieldset id="choices" aria-label="What do you want to achieve?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{objectives.map((choice) => (
<label
key={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200 bg-white hover:bg-slate-50",
"relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center">
<input
type="radio"
id={choice.id}
value={choice.label}
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 text-sm text-slate-700">
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "Other" && (
<div className="mt-4 w-full">
<Input
className="bg-white"
autoFocus
required
placeholder="Please specify"
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</label>
))}
</div>
</fieldset>
<div className="flex justify-between">
<Button className="text-slate-500" variant="minimal" onClick={next} id="objective-skip">
Skip
</Button>
<Button
variant="darkCTA"
loading={isProfileUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="onboarding-inapp-objective-next">
Next
</Button>
</div>
</div>
);
};

View File

@@ -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<RoleProps> = ({ setFormbricksResponseId, session, setCurrentStep }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(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<RoleChoice> = [
{ 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 (
<div className="flex w-full max-w-xl flex-col gap-8">
<OnboardingTitle
title="What is your role?"
subtitle="Make your Formbricks experience more personalised."
/>
<fieldset id="choices" aria-label="What is your role?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className="relative space-y-2 rounded-md">
{roles.map((choice) => (
<label
key={choice.id}
htmlFor={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
: "border-slate-200 bg-white hover:bg-slate-50",
"relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center">
<input
type="radio"
id={choice.id}
value={choice.label}
name="role"
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 text-sm text-slate-700">
{choice.label}
</span>
</span>
{choice.id === "other" && selectedChoice === "Other" && (
<div className="mt-4 w-full">
<Input
className="bg-white"
autoFocus
placeholder="Please specify"
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</label>
))}
</div>
</fieldset>
<div className="flex justify-between">
<Button className="text-slate-500" variant="minimal" onClick={next} id="role-skip">
Skip
</Button>
<Button
variant="darkCTA"
loading={isUpdating}
disabled={!selectedChoice}
onClick={handleNextClick}
id="onboarding-inapp-role-next">
Next
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,90 @@
"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<string | null>(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 (
<div className="flex flex-col items-center space-y-16">
<OnboardingTitle title="Create your first survey" subtitle="Pick a template or start from scratch." />
<div className="grid w-11/12 max-w-6xl grid-cols-3 grid-rows-1 gap-6">
{filteredTemplates.map((template) => {
const TemplateImage = templateImages[template.name];
const cssId = `onboarding-link-template-${template.name.toLowerCase().replace(/ /g, "-")}`;
return (
<OptionCard
cssId={cssId} // Use the generated cssId here
size="md"
key={template.name}
title={template.name}
description={template.description}
onSelect={() => newSurveyFromTemplate(template)}
loading={loadingTemplate === template.name}>
<Image src={TemplateImage} alt={template.name} className="rounded-md border border-slate-300" />
</OptionCard>
);
})}
</div>
<Button
id="onboarding-start-from-scratch"
size="lg"
variant="secondary"
loading={loadingTemplate === "Start from scratch"}
onClick={() => {
newSurveyFromTemplate(customSurvey);
}}>
Start from scratch <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import jsPackageJson from "@/../../packages/js/package.json";
import { finishOnboardingAction } from "@/app/(app)/onboarding/actions";
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 { useRouter } from "next/navigation";
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 router = useRouter();
const [selectedPathway, setSelectedPathway] = useState<string | null>(null);
const [progress, setProgress] = useState<number>(16);
const [formbricksResponseId, setFormbricksResponseId] = useState<string | undefined>();
const [currentStep, setCurrentStep] = useState<number | null>(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 (
<PathwaySelect
setSelectedPathway={setSelectedPathway}
setCurrentStep={setCurrentStep}
isFormbricksCloud={isFormbricksCloud}
/>
);
case 2:
return (
selectedPathway !== "link" && (
<Role
setFormbricksResponseId={setFormbricksResponseId}
session={session}
setCurrentStep={setCurrentStep}
/>
)
);
case 3:
return (
<Objective
formbricksResponseId={formbricksResponseId}
user={user}
setCurrentStep={setCurrentStep}
/>
);
case 4:
return (
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
jsPackageVersion={jsPackageJson.version}
setCurrentStep={setCurrentStep}
/>
);
case 5:
return selectedPathway === "link" ? (
<CreateFirstSurvey environmentId={environment.id} />
) : (
<InviteTeamMate environmentId={environment.id} team={team} setCurrentStep={setCurrentStep} />
);
default:
return null;
}
};
return (
<div className="flex h-full w-full flex-col items-center bg-slate-50">
<div className="hidden">
<button
id="FB__INTERNAL__SKIP_ONBOARDING"
onClick={async () => {
await finishOnboardingAction();
router.push(`/environments/${environment.id}/surveys`);
}}>
Skip onboarding
</button>
</div>
<OnboardingHeader progress={progress} />
<div className="mt-20 flex w-full justify-center bg-slate-50">
{renderOnboardingStep()}
{iframeVisible && isFormbricksCloud && (
<iframe
src={`https://app.formbricks.com/s/clr737oiseav88up09skt2hxo?userId=${session.user.id}`}
onLoad={() => setIframeLoaded(true)}
style={{
inset: "0",
position: "absolute",
width: "100%",
height: "100%",
border: "0",
zIndex: "40",
transition: "opacity 1s ease",
opacity: fade ? "1" : "0", // 1 for fade in, 0 for fade out
}}></iframe>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import PosthogIdentify from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
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 (
<div className="h-full w-full bg-slate-50">
<PosthogIdentify session={session} />
<ToasterClient />
{children}
</div>
);
}

View File

@@ -1,13 +0,0 @@
export default function Loading() {
return (
<div className="flex h-[100vh] w-[80vw] animate-pulse flex-col items-center justify-between p-12 text-white">
<div className="flex w-full justify-between">
<div className="h-12 w-1/6 rounded-lg bg-slate-200"></div>
<div className="h-12 w-1/3 rounded-lg bg-slate-200"></div>
<div className="h-0 w-1/6"></div>
</div>
<div className="h-1/3 w-1/2 rounded-lg bg-slate-200"></div>
<div className="h-10 w-1/2 rounded-lg bg-slate-200"></div>
</div>
);
}

View File

@@ -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 <Onboarding session={session} environmentId={environment.id} user={user} product={product} />;
return (
<Onboarding
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
session={session}
environment={environment}
user={user}
team={team}
webAppUrl={WEBAPP_URL}
/>
);
}

View File

@@ -1,4 +1,3 @@
// util.js
export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => {
if (event.key !== "Tab") {
return;

View File

@@ -241,7 +241,7 @@ export const SigninForm = ({
<span className="leading-5 text-slate-500">New to Formbricks?</span>
<br />
<Link
href={callbackUrl ? `/auth/signup?inviteToken=${inviteToken}` : "/auth/signup"}
href={inviteToken ? `/auth/signup?inviteToken=${inviteToken}` : "/auth/signup"}
className="font-semibold text-slate-600 underline hover:text-slate-700">
Create an account
</Link>

View File

@@ -3,7 +3,6 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest, userAgent } from "next/server";
import { getLatestActionByPersonId } from "@formbricks/lib/action/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import {
IS_FORMBRICKS_CLOUD,
@@ -11,7 +10,7 @@ import {
PRICING_USERTARGETING_FREE_MTU,
} from "@formbricks/lib/constants";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { createPerson, getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import {
@@ -95,10 +94,12 @@ export async function GET(
let person = await getPersonByUserId(environmentId, userId);
if (!isMauLimitReached) {
// MAU limit not reached: create person if not exists
if (!person) {
person = await createPerson(environmentId, userId);
}
} else {
// MAU limit reached: check if person has been active this month; only continue if person has been active
await sendFreeLimitReachedEventToPosthogBiWeekly(environmentId, "userTargeting");
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
if (!person) {
@@ -110,8 +111,8 @@ export async function GET(
);
} else {
// check if person has been active this month
const latestAction = await getLatestActionByPersonId(person.id);
if (!latestAction || new Date(latestAction.createdAt).getMonth() !== new Date().getMonth()) {
const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id);
if (!isPersonMonthlyActive) {
return responses.tooManyRequestsResponse(
errorMessage,
true,

View File

@@ -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 });
}

View File

@@ -36,6 +36,7 @@ export const questionTypes: TSurveyQuestionType[] = [
subheader: "Who? Who? Who?",
placeholder: "Type your answer here...",
longAnswer: true,
inputType: "text",
},
},
{

View File

@@ -162,9 +162,13 @@ export default function LinkSurvey({
getSetIsError={(f: (value: boolean) => void) => {
setIsError = f;
}}
getSetIsResponseSendingFinished={(f: (value: boolean) => void) => {
setIsResponseSendingFinished = f;
}}
getSetIsResponseSendingFinished={
!isPreview
? (f: (value: boolean) => void) => {
setIsResponseSendingFinished = f;
}
: undefined
}
onRetry={() => {
setIsError(false);
responseQueue.processQueue();

View File

@@ -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<MediaBackgroundProps> = ({
ContentRef,
}) => {
const animatedBackgroundRef = useRef<HTMLVideoElement>(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<MediaBackgroundProps> = ({
const renderBackground = () => {
const filterStyle = getFilterStyle();
const baseClasses = "absolute inset-0 h-full w-full";
switch (survey.styling?.background?.bgType) {
case "color":
return (
<div
className={`${baseClasses}`}
className={`${baseClasses} ${loadedClass}`}
style={{ backgroundColor: survey.styling?.background?.bg || "#ffff", filter: `${filterStyle}` }}
/>
);
@@ -55,23 +69,20 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
muted
loop
autoPlay
className={`${baseClasses} object-cover`}
className={`${baseClasses} ${loadedClass} object-cover`}
style={{ filter: `${filterStyle}` }}>
<source
src={survey.styling?.background?.bg || "/animated-bgs/Thumbnails/1_Thumb.mp4"}
type="video/mp4"
/>
<source src={survey.styling?.background?.bg || ""} type="video/mp4" />
</video>
);
case "image":
return (
<div
className={`${baseClasses} bg-cover bg-center`}
className={`${baseClasses} ${loadedClass} bg-cover bg-center`}
style={{ backgroundImage: `url(${survey.styling?.background?.bg})`, filter: `${filterStyle}` }}
/>
);
default:
return <div className={`${baseClasses} bg-white`} />;
return <div className={`${baseClasses} ${loadedClass} bg-white`} />;
}
};

View File

@@ -2,9 +2,11 @@
import { sendLinkSurveyEmailAction } from "@/app/s/[surveyId]/actions";
import { EnvelopeIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { useMemo, useState } from "react";
import { Toaster, toast } from "react-hot-toast";
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -19,6 +21,10 @@ export default function VerifyEmail({
isErrorComponent?: boolean;
singleUseId?: string;
}) {
survey = useMemo(() => {
return checkForRecallInHeadline(survey);
}, [survey]);
const [showPreviewQuestions, setShowPreviewQuestions] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState<boolean>(false);
@@ -106,7 +112,6 @@ export default function VerifyEmail({
)}
{!emailSent && showPreviewQuestions && (
<div>
{" "}
<p className="text-4xl font-bold">Question Preview</p>
<div className="mt-4 flex w-full flex-col justify-center rounded-lg border border-slate-200 bg-slate-50 bg-opacity-20 p-8 text-slate-700">
{survey.questions.map((question, index) => (
@@ -126,11 +131,8 @@ export default function VerifyEmail({
We sent an email to <span className="font-semibold italic">{email}</span>. Please click the link
in the email to take your survey.
</p>
<Button
variant="secondary"
className="mt-6 cursor-pointer text-sm text-slate-400"
onClick={handleGoBackClick}>
Go Back
<Button variant="secondary" className="mt-6" onClick={handleGoBackClick} StartIcon={ArrowLeft}>
Back
</Button>
</div>
)}

View File

@@ -8,12 +8,10 @@ import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL } from "@formbricks/lib/constants";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getResponseBySingleUseId, getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { ZId } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -23,11 +23,8 @@ export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
if (isWebAppRoute(request.nextUrl.pathname) && !token) {
const loginUrl = new URL(
`/auth/login?callbackUrl=${encodeURIComponent(request.nextUrl.toString())}`,
WEBAPP_URL
);
return NextResponse.redirect(loginUrl.href);
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");

View File

@@ -27,31 +27,31 @@
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-collapsible": "^1.0.3",
"@react-email/components": "^0.0.15",
"@sentry/nextjs": "^7.102.1",
"@sentry/nextjs": "^7.104.0",
"@vercel/og": "^0.6.2",
"@vercel/speed-insights": "^1.0.10",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"encoding": "^0.1.13",
"framer-motion": "11.0.6",
"framer-motion": "11.0.8",
"googleapis": "^133.0.0",
"jiti": "^1.21.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lru-cache": "^10.2.0",
"lucide-react": "^0.339.0",
"lucide-react": "^0.344.0",
"mime": "^4.0.1",
"next": "14.1.0",
"nodemailer": "^6.9.10",
"next": "14.1.1",
"nodemailer": "^6.9.11",
"otplib": "^12.0.1",
"posthog-js": "^1.108.3",
"posthog-js": "^1.110.0",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-email": "^2.1.0",
"react-hook-form": "^7.50.1",
"react-hook-form": "^7.51.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1",
"sharp": "^0.33.2",

View File

@@ -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/);

View File

@@ -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/);
@@ -64,7 +64,8 @@ test.describe("JS Package Test", async () => {
// Formbricks Modal is visible
await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible();
await page.waitForTimeout(1000);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1500);
});
test("Admin checks Display", async ({ page }) => {
@@ -74,9 +75,11 @@ test.describe("JS Package Test", async () => {
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 1 Display
await page.waitForTimeout(1000);
await expect(page.getByText("Displays1")).toBeVisible();
// Survey should have 0 Responses
await page.waitForTimeout(1000);
await expect(page.getByRole("button", { name: "Responses0% -" })).toBeVisible();
});
@@ -115,7 +118,7 @@ test.describe("JS Package Test", async () => {
// Formbricks Modal is not visible
await expect(page.getByText("Powered by Formbricks")).not.toBeVisible({ timeout: 10000 });
await page.waitForLoadState("networkidle");
await page.waitForTimeout(500);
await page.waitForTimeout(1500);
});
test("Admin validates Response", async ({ page }) => {
@@ -125,8 +128,11 @@ test.describe("JS Package Test", async () => {
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 2 Displays
await page.waitForTimeout(1000);
await expect(page.getByText("Displays2")).toBeVisible();
// Survey should have 1 Response
await page.waitForTimeout(1000);
await expect(page.getByRole("button", { name: "Responses50%" })).toBeVisible();
await expect(page.getByText("1 responses", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Clickthrough Rate (CTR)100%")).toBeVisible();

View File

@@ -3,48 +3,38 @@ 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.getByRole("button", { name: "Link Surveys Create a new" }).click();
await page.getByRole("button", { name: "Collect Feedback Collect" }).click();
await page.getByRole("button", { name: "Save" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText(productName)).toBeVisible();
});
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: "In-app Surveys Run a survey" }).click();
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: "Skip" }).click();
await page.getByRole("button", { name: "Skip" }).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();
});
});

View File

@@ -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();
@@ -58,7 +58,7 @@ test.describe("Invite, accept and remove team member", async () => {
const pendingSpan = lastMemberInfo.locator("span").filter({ hasText: "Pending" });
await expect(pendingSpan).toBeVisible();
const shareInviteButton = page.locator("#shareInviteButton");
const shareInviteButton = page.locator(".shareInviteButton").last();
await expect(shareInviteButton).toBeVisible();
await shareInviteButton.click();
@@ -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 }) => {

View File

@@ -12,11 +12,19 @@ export const signUpAndLogin = async (
await page.goto("/auth/login");
await page.getByRole("link", { name: "Create an account" }).click();
await page.getByRole("button", { name: "Continue with Email" }).click();
await expect(page.getByPlaceholder("Full Name")).toBeVisible();
await page.getByPlaceholder("Full Name").fill(name);
await page.getByPlaceholder("Full Name").press("Tab");
await expect(page.getByPlaceholder("work@email.com")).toBeVisible();
await page.getByPlaceholder("work@email.com").click();
await page.getByPlaceholder("work@email.com").fill(email);
await page.getByPlaceholder("work@email.com").press("Tab");
await expect(page.getByPlaceholder("*******")).toBeVisible();
await page.getByPlaceholder("*******").click();
await page.getByPlaceholder("*******").fill(password);
await page.getByRole("button", { name: "Continue with Email" }).click();
@@ -30,21 +38,39 @@ export const signUpAndLogin = async (
export const login = async (page: Page, email: string, password: string): Promise<void> => {
await page.goto("/auth/login");
await expect(page.getByRole("button", { name: "Login with Email" })).toBeVisible();
await page.getByRole("button", { name: "Login with Email" }).click();
await expect(page.getByPlaceholder("work@email.com")).toBeVisible();
await page.getByPlaceholder("work@email.com").fill(email);
await expect(page.getByPlaceholder("*******")).toBeVisible();
await page.getByPlaceholder("*******").click();
await page.getByPlaceholder("*******").fill(password);
await page.getByRole("button", { name: "Login with Email" }).click();
};
export const skipOnboarding = async (page: Page): Promise<void> => {
export const finishOnboarding = async (page: Page): Promise<void> => {
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();
const hiddenSkipButton = page.locator("#FB__INTERNAL__SKIP_ONBOARDING");
hiddenSkipButton.evaluate((el: HTMLElement) => el.click());
// await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
// await page.getByRole("button", { name: "Skip" }).click();
// await page.getByRole("button", { name: "Skip" }).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 +113,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();

View File

@@ -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",
},
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -32,13 +32,13 @@
"prepare": "husky install"
},
"devDependencies": {
"@playwright/test": "^1.41.1",
"@playwright/test": "^1.42.1",
"eslint-config-formbricks": "workspace:*",
"husky": "^9.0.5",
"lint-staged": "^15.2.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"rimraf": "^5.0.5",
"tsx": "^4.7.0",
"turbo": "^1.11.3"
"tsx": "^4.7.1",
"turbo": "^1.12.4"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
@@ -64,6 +64,6 @@
},
"dependencies": {
"@changesets/cli": "^2.27.1",
"playwright": "^1.41.1"
"playwright": "^1.42.1"
}
}

View File

@@ -2,6 +2,7 @@ import { TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { TIntegrationConfig } from "@formbricks/types/integration";
import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/responses";
import { TBaseFilters } from "@formbricks/types/segment";
import { TStyling } from "@formbricks/types/styling";
import {
TSurveyClosedMessage,
TSurveyHiddenFields,
@@ -38,5 +39,6 @@ declare global {
export type UserNotificationSettings = TUserNotificationSettings;
export type SegmentFilter = TBaseFilters;
export type SurveyInlineTriggers = TSurveyInlineTriggers;
export type Styling = TStyling;
}
}

View File

@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "Action_actionClassId_idx";
-- CreateIndex
CREATE INDEX "Action_personId_actionClassId_created_at_idx" ON "Action"("personId", "actionClassId", "created_at");
-- CreateIndex
CREATE INDEX "Action_actionClassId_created_at_idx" ON "Action"("actionClassId", "created_at");

View File

@@ -0,0 +1,57 @@
import { PrismaClient } from "@prisma/client";
import { TStyling } from "@formbricks/types/styling";
const prisma = new PrismaClient();
async function main() {
await prisma.$transaction(async (tx) => {
// product table with brand color and the highlight border color (if available)
// styling object needs to be created for each product
const products = await tx.product.findMany({});
if (!products) {
// something went wrong, could not find any products
return;
}
const updates = products.map(product => {
if (product.styling !== null) {
// styling object already exists for this product
return;
}
const styling: TStyling = {
unifiedStyling: true,
allowStyleOverwrite: true,
brandColor: {
light: product.brandColor,
},
...(product.highlightBorderColor && {
highlightBorderColor: {
light: product.highlightBorderColor,
dark: product.highlightBorderColor,
},
}),
};
return tx.product.update({
where: {
id: product.id,
},
data: {
styling,
},
});
});
await tx.$transaction(updates);
});
}
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "styling" JSONB DEFAULT '{"unifiedStyling":true,"allowStyleOverwrite":true,"brandColor":{"light":"#64748b"}}';

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- A unique constraint covering the columns `[personId,attributeClassId]` on the table `Attribute` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "Attribute_attributeClassId_personId_key";
-- DropIndex
DROP INDEX "AttributeClass_environmentId_idx";
-- CreateIndex
CREATE UNIQUE INDEX "Attribute_personId_attributeClassId_key" ON "Attribute"("personId", "attributeClassId");
-- CreateIndex
CREATE INDEX "AttributeClass_environmentId_created_at_idx" ON "AttributeClass"("environmentId", "created_at");
-- CreateIndex
CREATE INDEX "AttributeClass_environmentId_archived_idx" ON "AttributeClass"("environmentId", "archived");

View File

@@ -23,11 +23,12 @@
"lint": "eslint ./src --fix",
"post-install": "pnpm generate",
"predev": "pnpm generate",
"data-migration:v1.6": "ts-node ./migrations/20240207041922_advanced_targeting/data-migration.ts"
"data-migration:v1.6": "ts-node ./migrations/20240207041922_advanced_targeting/data-migration.ts",
"data-migration:styling": "ts-node ./migrations/20240229062232_adds_styling_column_to_product_model/data-migration.ts"
},
"dependencies": {
"@prisma/client": "^5.10.2",
"@prisma/extension-accelerate": "^0.6.3",
"@prisma/extension-accelerate": "^1.0.0",
"@t3-oss/env-nextjs": "^0.9.2",
"dotenv-cli": "^7.3.0"
},

View File

@@ -63,7 +63,7 @@ model Attribute {
personId String
value String
@@unique([attributeClassId, personId])
@@unique([personId, attributeClassId])
}
enum AttributeType {
@@ -86,7 +86,8 @@ model AttributeClass {
attributeFilters SurveyAttributeFilter[]
@@unique([name, environmentId])
@@index([environmentId])
@@index([environmentId, createdAt])
@@index([environmentId, archived])
}
model Person {
@@ -350,8 +351,9 @@ model Action {
/// [ActionProperties]
properties Json @default("{}")
@@index([personId, actionClassId, createdAt])
@@index([actionClassId, createdAt])
@@index([personId])
@@index([actionClassId])
}
enum EnvironmentType {
@@ -417,6 +419,9 @@ model Product {
environments Environment[]
brandColor String @default("#64748b")
highlightBorderColor String?
/// @zod.custom(imports.ZStyling)
/// [Styling]
styling Json? @default("{\"unifiedStyling\":true,\"allowStyleOverwrite\":true,\"brandColor\":{\"light\":\"#64748b\"}}")
recontactDays Int @default(7)
linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys

View File

@@ -1,5 +1,7 @@
import z from "zod";
export { ZStyling } from "@formbricks/types/styling";
export const ZActionProperties = z.record(z.string());
export { ZActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
export { ZIntegrationConfig } from "@formbricks/types/integration";

View File

@@ -19,6 +19,6 @@
"dependencies": {
"@formbricks/lib": "workspace:*",
"@t3-oss/env-nextjs": "^0.9.2",
"stripe": "^14.18.0"
"stripe": "^14.19.0"
}
}

View File

@@ -9,7 +9,7 @@
},
"devDependencies": {
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.0",
"eslint-config-next": "^14.1.1",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "1.10.12",
"eslint-plugin-react": "7.33.2",

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.6.0",
"version": "1.6.1",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
@@ -39,16 +39,16 @@
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.23.3",
"@formbricks/api": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"cross-env": "^7.0.3",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "1.10.12",

View File

@@ -13,8 +13,11 @@ import { logoutPerson, resetPerson, setPersonAttribute, setPersonUserId } from "
declare global {
interface Window {
formbricksSurveys: {
renderSurveyInline: (props: SurveyInlineProps & { brandColor: string }) => void;
renderSurveyModal: (props: SurveyModalProps & { brandColor: string }) => void;
// renderSurveyInline: (props: SurveyInlineProps & { brandColor: string }) => void;
// renderSurveyModal: (props: SurveyModalProps & { brandColor: string }) => void;
renderSurveyInline: (props: SurveyInlineProps) => void;
renderSurveyModal: (props: SurveyModalProps) => void;
};
}
}

View File

@@ -46,7 +46,10 @@ export class CommandQueue {
if (currentItem.checkInitialized) {
const initResult = checkInitialized();
if (initResult && initResult.ok !== true) errorHandler.handle(initResult.error);
if (initResult && initResult.ok !== true) {
errorHandler.handle(initResult.error);
continue;
}
}
const executeCommand = async () => {

View File

@@ -25,11 +25,12 @@ export class Config {
public update(newConfig: TJsConfigUpdateInput): void {
if (newConfig) {
const expiresAt = new Date(new Date().getTime() + 2 * 60000); // 2 minutes from now
const expiresAt = new Date(new Date().getTime() + 2 * 60000); // 2 minutes in the future
this.config = {
...this.config,
...newConfig,
status: newConfig.status || "success",
expiresAt,
};

View File

@@ -2,7 +2,7 @@ import type { TJsConfig, TJsConfigInput } from "@formbricks/types/js";
import { TPersonAttributes } from "@formbricks/types/people";
import { trackAction } from "./actions";
import { Config } from "./config";
import { Config, LOCAL_STORAGE_KEY } from "./config";
import {
ErrorHandler,
MissingFieldError,
@@ -12,6 +12,7 @@ import {
Result,
err,
okVoid,
wrapThrows,
} from "./errors";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { Logger } from "./logger";
@@ -19,23 +20,46 @@ import { checkPageUrl } from "./noCodeActions";
import { updatePersonAttributes } from "./person";
import { sync } from "./sync";
import { getIsDebug } from "./utils";
import { addWidgetContainer, closeSurvey } from "./widget";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
let isInitialized = false;
export const setIsInitialized = (value: boolean) => {
isInitialized = value;
};
export const initialize = async (
c: TJsConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (getIsDebug()) {
logger.configure({ logLevel: "debug" });
}
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
return okVoid();
}
if (getIsDebug()) {
logger.configure({ logLevel: "debug" });
let existingConfig: TJsConfig | undefined;
try {
existingConfig = config.get();
logger.debug("Found existing configuration.");
} catch (e) {
logger.debug("No existing configuration found.");
}
// formbricks is in error state, skip initialization
if (existingConfig?.status === "error") {
logger.debug("Formbricks was set to an error state.");
if (existingConfig?.expiresAt && new Date(existingConfig.expiresAt) > new Date()) {
logger.debug("Error state is not expired, skipping initialization");
return okVoid();
} else {
logger.debug("Error state is expired. Continue with initialization.");
}
}
ErrorHandler.getInstance().printStatus();
@@ -81,13 +105,6 @@ export const initialize = async (
updatedAttributes = res.value;
}
let existingConfig: TJsConfig | undefined;
try {
existingConfig = config.get();
} catch (e) {
logger.debug("No existing configuration found.");
}
if (
existingConfig &&
existingConfig.state &&
@@ -96,29 +113,39 @@ export const initialize = async (
existingConfig.userId === c.userId &&
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
) {
logger.debug("Found existing configuration.");
logger.debug("Configuration fits init parameters.");
if (existingConfig.expiresAt < new Date()) {
logger.debug("Configuration expired.");
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
try {
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
putFormbricksInErrorState();
}
} else {
logger.debug("Configuration not expired. Extending expiration.");
config.update(existingConfig);
}
} else {
logger.debug("No valid configuration found or it has been expired. Creating new config.");
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
config.resetConfig();
logger.debug("Syncing.");
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
try {
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
handleErrorOnFirstInit();
}
// and track the new session event
await trackAction("New Session");
}
@@ -140,7 +167,7 @@ export const initialize = async (
addEventListeners();
addCleanupEventListeners();
isInitialized = true;
setIsInitialized(true);
logger.debug("Initialized");
// check page url if initialized after page load
@@ -149,6 +176,17 @@ export const initialize = async (
return okVoid();
};
const handleErrorOnFirstInit = () => {
// put formbricks in error state (by creating a new config) and throw error
const initialErrorConfig: Partial<TJsConfig> = {
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
};
// can't use config.update here because the config is not yet initialized
wrapThrows(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
throw new Error("Could not initialize formbricks");
};
export const checkInitialized = (): Result<void, NotInitializedError> => {
logger.debug("Check if initialized");
if (!isInitialized || !ErrorHandler.initialized) {
@@ -163,8 +201,19 @@ export const checkInitialized = (): Result<void, NotInitializedError> => {
export const deinitalize = (): void => {
logger.debug("Deinitializing");
closeSurvey();
removeWidgetContainer();
setIsSurveyRunning(false);
removeAllEventListeners();
config.resetConfig();
isInitialized = false;
setIsInitialized(false);
};
export const putFormbricksInErrorState = (): void => {
logger.debug("Putting formbricks in error state");
// change formbricks status to error
config.update({
...config.get(),
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
});
deinitalize();
};

View File

@@ -13,7 +13,6 @@ import {
} from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { sync } from "./sync";
import { closeSurvey } from "./widget";
const config = Config.getInstance();
@@ -55,15 +54,7 @@ export const updatePersonAttribute = async (
}
if (res.data.changed) {
logger.debug("Attribute updated. Syncing...");
await sync(
{
environmentId: environmentId,
apiHost: apiHost,
userId: userId,
},
true
);
logger.debug("Attribute updated in Formbricks");
}
return okVoid();
@@ -178,6 +169,7 @@ export const setPersonAttribute = async (
export const logoutPerson = async (): Promise<void> => {
deinitalize();
config.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {

View File

@@ -15,21 +15,42 @@ const syncWithBackend = async (
{ apiHost, environmentId, userId }: TJsSyncParams,
noCache: boolean
): Promise<Result<TJsStateSync, NetworkError>> => {
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
const urlSuffix = `?version=${import.meta.env.VERSION}`;
try {
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
const urlSuffix = `?version=${import.meta.env.VERSION}`;
let fetchOptions: RequestInit = {};
let fetchOptions: RequestInit = {};
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
// if user id is available
// if user id is not available
if (!userId) {
const url = baseUrl + urlSuffix;
// public survey
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
return ok((await response.json()).data as TJsState);
}
// userId is available, call the api with the `userId` param
const url = `${baseUrl}/${userId}${urlSuffix}`;
if (!userId) {
const url = baseUrl + urlSuffix;
// public survey
const response = await fetch(url, fetchOptions);
if (!response.ok) {
@@ -44,38 +65,20 @@ const syncWithBackend = async (
});
}
return ok((await response.json()).data as TJsState);
const data = await response.json();
const { data: state } = data;
return ok(state as TJsStateSync);
} catch (e) {
return err(e as NetworkError);
}
// userId is available, call the api with the `userId` param
const url = `${baseUrl}/${userId}${urlSuffix}`;
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
const data = await response.json();
const { data: state } = data;
return ok(state as TJsStateSync);
};
export const sync = async (params: TJsSyncParams, noCache = false): Promise<void> => {
try {
const syncResult = await syncWithBackend(params, noCache);
if (syncResult?.ok !== true) {
logger.error(`Sync failed: ${JSON.stringify(syncResult.error)}`);
throw syncResult.error;
}
@@ -115,8 +118,6 @@ export const sync = async (params: TJsSyncParams, noCache = false): Promise<void
userId: params.userId,
state,
});
// before finding the surveys, check for public use
} catch (error) {
logger.error(`Error during sync: ${error}`);
throw error;
@@ -175,17 +176,23 @@ export const addExpiryCheckListener = (): void => {
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined" && syncIntervalId === null) {
syncIntervalId = window.setInterval(async () => {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
try {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
});
} catch (e) {
logger.error(`Error during expiry check: ${e}`);
logger.debug("Extending config and try again later.");
const existingConfig = config.get();
config.update(existingConfig);
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
// personId: config.get().state?.person?.id,
});
}, updateInterval);
}
};

View File

@@ -7,23 +7,29 @@ import { TSurvey } from "@formbricks/types/surveys";
import { Config } from "./config";
import { ErrorHandler } from "./errors";
import { putFormbricksInErrorState } from "./initialize";
import { Logger } from "./logger";
import { filterPublicSurveys, sync } from "./sync";
const containerId = "formbricks-web-container";
const config = Config.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let surveyRunning = false;
let isSurveyRunning = false;
let setIsError = (_: boolean) => {};
let setIsResponseSendingFinished = (_: boolean) => {};
export const setIsSurveyRunning = (value: boolean) => {
isSurveyRunning = value;
};
export const renderWidget = async (survey: TSurvey) => {
if (surveyRunning) {
if (isSurveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
}
surveyRunning = true;
setIsSurveyRunning(false);
if (survey.delay) {
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
@@ -163,7 +169,7 @@ export const renderWidget = async (survey: TSurvey) => {
export const closeSurvey = async (): Promise<void> => {
// remove container element from DOM
document.getElementById(containerId)?.remove();
removeWidgetContainer();
addWidgetContainer();
// if unidentified user, refilter the surveys
@@ -174,7 +180,7 @@ export const closeSurvey = async (): Promise<void> => {
...config.get(),
state: updatedState,
});
surveyRunning = false;
setIsSurveyRunning(false);
return;
}
@@ -188,9 +194,10 @@ export const closeSurvey = async (): Promise<void> => {
},
true
);
surveyRunning = false;
} catch (e) {
setIsSurveyRunning(false);
} catch (e: any) {
errorHandler.handle(e);
putFormbricksInErrorState();
}
};
@@ -200,6 +207,10 @@ export const addWidgetContainer = (): void => {
document.body.appendChild(containerElement);
};
export const removeWidgetContainer = (): void => {
document.getElementById(containerId)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
const formbricksSurveysScriptSrc = import.meta.env.FORMBRICKS_SURVEYS_SCRIPT_SRC;

View File

@@ -14,112 +14,14 @@ import { DatabaseError } from "@formbricks/types/errors";
import { actionClassCache } from "../actionClass/cache";
import { createActionClass, getActionClassByEnvironmentIdAndName } from "../actionClass/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { createPerson, getPersonByUserId } from "../person/service";
import { activePersonCache } from "../person/cache";
import { createPerson, getIsPersonMonthlyActive, getPersonByUserId } from "../person/service";
import { surveyCache } from "../survey/cache";
import { formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { actionCache } from "./cache";
import { getStartDateOfLastMonth, getStartDateOfLastQuarter, getStartDateOfLastWeek } from "./utils";
export const getLatestActionByEnvironmentId = async (environmentId: string): Promise<TAction | null> => {
const action = await unstable_cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const actionPrisma = await prisma.action.findFirst({
where: {
actionClass: {
environmentId: environmentId,
},
},
orderBy: {
createdAt: "desc",
},
include: {
actionClass: true,
},
});
if (!actionPrisma) {
return null;
}
const action: TAction = {
id: actionPrisma.id,
createdAt: actionPrisma.createdAt,
personId: actionPrisma.personId,
properties: actionPrisma.properties,
actionClass: actionPrisma.actionClass,
};
return action;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
},
[`getLastestActionByEnvironmentId-${environmentId}`],
{
tags: [actionCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
// since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them
// https://github.com/vercel/next.js/issues/51613
return action ? formatDateFields(action, ZAction) : null;
};
export const getLatestActionByPersonId = async (personId: string): Promise<TAction | null> => {
const action = await unstable_cache(
async () => {
validateInputs([personId, ZId]);
try {
const actionPrisma = await prisma.action.findFirst({
where: {
personId,
},
orderBy: {
createdAt: "desc",
},
include: {
actionClass: true,
},
});
if (!actionPrisma) {
return null;
}
const action: TAction = {
id: actionPrisma.id,
createdAt: actionPrisma.createdAt,
personId: actionPrisma.personId,
properties: actionPrisma.properties,
actionClass: actionPrisma.actionClass,
};
return action;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
},
[`getLastestActionByPersonId-${personId}`],
{
tags: [actionCache.tag.byPersonId(personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
// since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them
// https://github.com/vercel/next.js/issues/51613
return action ? formatDateFields(action, ZAction) : null;
};
export const getActionsByPersonId = async (personId: string, page?: number): Promise<TAction[]> => {
const actions = await unstable_cache(
async () => {
@@ -257,6 +159,11 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
},
});
const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id);
if (!isPersonMonthlyActive) {
activePersonCache.revalidate({ id: person.id });
}
actionCache.revalidate({
environmentId,
personId: person.id,

View File

@@ -74,7 +74,6 @@ export const SMTP_PASSWORD = env.SMTP_PASSWORD;
export const MAIL_FROM = env.MAIL_FROM;
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
export const NEXTAUTH_URL = env.NEXTAUTH_URL;
export const ITEMS_PER_PAGE = 50;
export const RESPONSES_PER_PAGE = 10;
export const TEXT_RESPONSES_PER_PAGE = 5;

View File

@@ -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},<br/><br/>
Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:<br/><br/>
<a class="button" href="${verifyLink}">Join team</a><br/>
<br/>
Have a great day!<br/>
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 👋,<br/><br/>
${inviteMessage}
<h2>Get Started in Minutes</h2>
<ol>
<li>Create an account to join ${inviterName}'s team.</li>
<li>Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.</li>
<li>Done ✅</li>
</ol>
<a class="button" href="${verifyLink}">Join ${inviterName}'s team</a><br/>
<br/>
Have a great day!<br/>
The Formbricks Team!`),
});
} else {
await sendEmail({
to: email,
subject: `You're invited to collaborate on Formbricks!`,
html: withEmailTemplate(`Hey ${inviteeName},<br/><br/>
Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:<br/><br/>
<a class="button" href="${verifyLink}">Join team</a><br/>
<br/>
Have a great day!<br/>
The Formbricks Team!`),
});
}
};
export const sendInviteAcceptedEmail = async (inviterName: string, inviteeName: string, email: string) => {

View File

@@ -194,10 +194,14 @@ export const inviteUser = async ({
currentUser,
invitee,
teamId,
isOnboardingInvite,
inviteMessage,
}: {
teamId: string;
invitee: TInvitee;
currentUser: TCurrentUser;
isOnboardingInvite?: boolean;
inviteMessage?: string;
}): Promise<TInvite> => {
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;
};

View File

@@ -14,9 +14,9 @@
"test": "dotenv -e ../../.env -- vitest run"
},
"dependencies": {
"@aws-sdk/client-s3": "3.521.0",
"@aws-sdk/s3-presigned-post": "3.521.0",
"@aws-sdk/s3-request-presigner": "3.521.0",
"@aws-sdk/client-s3": "3.525.0",
"@aws-sdk/s3-presigned-post": "3.525.0",
"@aws-sdk/s3-request-presigner": "3.525.0",
"@formbricks/api": "*",
"@formbricks/database": "*",
"@formbricks/types": "*",
@@ -29,14 +29,14 @@
"mime-types": "^2.1.35",
"nanoid": "^5.0.6",
"next-auth": "^4.24.6",
"nodemailer": "^6.9.10",
"nodemailer": "^6.9.11",
"posthog-node": "^3.6.3",
"server-only": "^0.0.1",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@formbricks/tsconfig": "*",
"@types/jsonwebtoken": "^9.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mime-types": "^2.1.4",
"dotenv": "^16.4.5",
"eslint-config-formbricks": "workspace:*",

View File

@@ -32,3 +32,22 @@ export const personCache = {
}
},
};
interface ActivePersonRevalidateProps {
id?: string;
environmentId?: string;
userId?: string;
}
export const activePersonCache = {
tag: {
byId(personId: string): string {
return `people-${personId}-active`;
},
},
revalidate({ id }: ActivePersonRevalidateProps): void {
if (id) {
revalidateTag(this.tag.byEnvironmentId(id));
}
},
};

View File

@@ -13,7 +13,7 @@ import { createAttributeClass, getAttributeClassByName } from "../attributeClass
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { personCache } from "./cache";
import { activePersonCache, personCache } from "./cache";
export const selectPerson = {
id: true,
@@ -258,7 +258,7 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
// Now perform the upsert for the attribute with the found or created attributeClassId
await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
personId_attributeClassId: {
attributeClassId: attributeClass!.id,
personId,
},
@@ -391,7 +391,7 @@ export const updatePersonAttribute = async (
const attributes = await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
personId_attributeClassId: {
attributeClassId,
personId,
},
@@ -420,3 +420,29 @@ export const updatePersonAttribute = async (
return attributes;
};
export const getIsPersonMonthlyActive = async (personId: string): Promise<boolean> =>
unstable_cache(
async () => {
const latestAction = await prisma.action.findFirst({
where: {
personId,
},
orderBy: {
createdAt: "desc",
},
select: {
createdAt: true,
},
});
if (!latestAction || new Date(latestAction.createdAt).getMonth() !== new Date().getMonth()) {
return false;
}
return true;
},
[`isPersonActive-${personId}`],
{
tags: [activePersonCache.tag.byId(personId)],
revalidate: 60 * 60 * 24, // 24 hours
}
)();

View File

@@ -34,6 +34,7 @@ const selectProduct = {
clickOutsideClose: true,
darkOverlay: true,
environments: true,
styling: true,
};
export const getProducts = async (teamId: string, page?: number): Promise<TProduct[]> => {

Some files were not shown because too many files have changed in this diff Show More