Compare commits
42 Commits
v1.6.0
...
ReviewBot/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40df928974 | ||
|
|
7a1af85141 | ||
|
|
2586a3ba3a | ||
|
|
77408bf0b0 | ||
|
|
d5b183155b | ||
|
|
6c1989b527 | ||
|
|
96bc0e669c | ||
|
|
9791490449 | ||
|
|
ee053e6642 | ||
|
|
56f6dbe9a6 | ||
|
|
49c18023bd | ||
|
|
3f5f29122b | ||
|
|
2ed03bc8da | ||
|
|
5aa72a4c76 | ||
|
|
4a8fdcbbbc | ||
|
|
5855804291 | ||
|
|
9fdb7452a2 | ||
|
|
7573b2d0ba | ||
|
|
cdd93ee86b | ||
|
|
dbbd450b62 | ||
|
|
d7fc7995bc | ||
|
|
255f1cee61 | ||
|
|
a9d8239a25 | ||
|
|
36ac4ecdb9 | ||
|
|
4edb92365a | ||
|
|
89eee21978 | ||
|
|
71bdb5095a | ||
|
|
b84e322eee | ||
|
|
a8563ad905 | ||
|
|
06eebe36ee | ||
|
|
5fc18fc445 | ||
|
|
53d3be3b27 | ||
|
|
08ccb954f3 | ||
|
|
38c6cb01df | ||
|
|
2c13121487 | ||
|
|
73f1d09dc8 | ||
|
|
1f884a408c | ||
|
|
ed2253dcfc | ||
|
|
078c5db2b0 | ||
|
|
356d237e60 | ||
|
|
e799aa9b37 | ||
|
|
b36a263ef6 |
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and that’s 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">
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />}</>;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.">
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
18
apps/web/app/(app)/onboarding/components/OnboardingTitle.tsx
Normal 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;
|
||||
69
apps/web/app/(app)/onboarding/components/PathwaySelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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;
|
||||
22
apps/web/app/(app)/onboarding/components/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <head> 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
160
apps/web/app/(app)/onboarding/components/inapp/SurveyRole.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
180
apps/web/app/(app)/onboarding/components/onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/web/app/(app)/onboarding/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// util.js
|
||||
export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const questionTypes: TSurveyQuestionType[] = [
|
||||
subheader: "Who? Who? Who?",
|
||||
placeholder: "Type your answer here...",
|
||||
longAnswer: true,
|
||||
inputType: "text",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`} />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
BIN
apps/web/images/onboarding-churn.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/web/images/onboarding-collect-feedback.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/web/images/onboarding-dance.gif
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/images/onboarding-in-app-survey.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/images/onboarding-link-survey.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
apps/web/images/onboarding-lost.gif
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
apps/web/images/onboarding-nps.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
BIN
apps/web/public/onboarding/meme.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
12
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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());
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "styling" JSONB DEFAULT '{"unifiedStyling":true,"allowStyleOverwrite":true,"brandColor":{"light":"#64748b"}}';
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "^0.9.2",
|
||||
"stripe": "^14.18.0"
|
||||
"stripe": "^14.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>> => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)();
|
||||
|
||||
@@ -34,6 +34,7 @@ const selectProduct = {
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
environments: true,
|
||||
styling: true,
|
||||
};
|
||||
|
||||
export const getProducts = async (teamId: string, page?: number): Promise<TProduct[]> => {
|
||||
|
||||